JVector原ReadMe

近似最近邻搜索(ANN)简介

精确的最近邻搜索(KNN)在高维空间中计算代价非常高,因为在2D或3D中有效的搜索空间划分方法(如 quadtree 或 k-d tree)在高维中退化为线性扫描。这种现象就是所谓的“维度灾难”。

在大规模数据集中,相较于线性时间获得精确结果,能够在对数时间内获得近似结果通常更有价值。这种方法被称为近似最近邻搜索(Approximate Nearest Neighbor,ANN)。

ANN 索引主要分为两类:

图索引不仅实现简单、搜索速度快,更重要的是它们支持增量构建和更新。这一点比起只能处理静态数据集的分区方法优势明显。因此,所有主流商业向量数据库都采用图索引。

JVector 是一种图索引方法,融合了 DiskANN 与 HNSW 的设计思想。
它借鉴了 HNSW 的分层结构,同时在每一层中使用了 DiskANN 背后的 Vamana 算法。


JVector 架构

JVector 是一种基于图的索引系统,在 HNSW 和 DiskANN 的基础上实现了可组合的扩展能力。

它构建了一个多层图结构,并实现了无阻塞并发控制,使得构建过程可线性扩展至多个 CPU 核心:

JVector 在增加线程数时线性扩展

架构要点如下:

  • 上层图:每个节点存储内存中的邻接表,用于快速导航,无需磁盘 IO。

  • 底层图:邻接表存储在磁盘上,并附带额外信息用于两阶段搜索。

    • 第一阶段:在内存中使用压缩后的向量进行粗略搜索(可使用 PQ / BQ / Fused ADC)
    • 第二阶段:从磁盘读取更精确的表示进行精排(使用 float32 或 NVQ)

两阶段设计在保持准确性的同时显著降低内存占用与延迟
Why Vector Size Matters

JVector 还可以使用两阶段搜索来构建索引,从而实现构建超过内存大小的数据集

支持更大索引

这意味着可以在一个索引内部实现对数时间搜索,而不是多个索引线性合并。


JVector 使用步骤(Step-by-Step)

所有代码示例来自 JVector 仓库中的 SiftSmall

第一步:在内存中构建并查询索引

核心代码如下:

public static void siftInMemory(ArrayList<VectorFloat<?>> baseVectors) throws IOException {
    ...
    // 构建索引
    OnHeapGraphIndex index = builder.build(ravv);
    
    // 执行搜索
    SearchResult sr = GraphSearcher.search(q, 10, ravv, VectorSimilarityFunction.EUCLIDEAN, index, Bits.ALL);
    ...
}

说明:

  • 所有向量必须维度一致。

  • RAVV(RandomAccessVectorValues)用于抽象访问向量。

  • 构建参数:

    • graph degree:每个节点的邻接边数量(通常为16)
    • overflowalpha:控制图构建过程中的冗余与多样性
    • true 表示使用多层图(Hierarchical)

第二步:使用 GraphSearcher 显式控制搜索器

try (GraphSearcher searcher = new GraphSearcher(index)) {
    SearchScoreProvider ssp = SearchScoreProvider.exact(q, VectorSimilarityFunction.EUCLIDEAN, ravv);
    SearchResult sr = searcher.search(ssp, 10, Bits.ALL);
}

说明:

  • GraphSearcher 初始化代价较高,推荐使用对象池复用。
  • SearchScoreProvider 用于定义搜索评分策略(精确/近似 + 可能的 reranker)。

第三步:评估召回率(Recall)

Function<VectorFloat<?>, SearchScoreProvider> sspFactory = ...;
testRecall(index, queryVectors, groundTruth, sspFactory);

返回结果如:

(OnHeapGraphIndex) Recall: 0.9898

第四步:索引持久化到磁盘并加载

OnDiskGraphIndex.write(index, ravv, indexPath);
...
OnDiskGraphIndex index = OnDiskGraphIndex.load(rs);

说明:

  • 磁盘索引读取器为 ReaderSupplier,用于创建 RandomAccessReader 实例。
  • 推荐使用 MemorySegmentReader(Java 20+),或兼容 Java 11 的 SimpleMappedReader(仅限 <=2GB 文件)。

第五步:使用压缩向量进行搜索

构建压缩向量(PQ):

ProductQuantization pq = ProductQuantization.compute(ravv, 16, 256, true);
PQVectors pqv = pq.encodeAll(ravv);

两阶段搜索(压缩 + 精排):

ApproximateScoreFunction asf = pqv.precomputedScoreFunctionFor(q, ...);
Reranker reranker = index.getView().rerankerFor(q, ...);
SearchScoreProvider provider = new SearchScoreProvider(asf, reranker);

第六步:构建超内存大小的索引(增量构建)

  • 只将压缩后的 PQ 向量留在内存中,原始向量存入磁盘。
  • 每个向量执行以下操作:
incrementallyCompressedVectors.add(pq.encode(v));
writer.writeInline(ordinal, ...);  // 写入磁盘
builder.addGraphNode(ordinal, v);  // 添加节点到图

最后执行清理和保存:

builder.cleanup();
writer.write(Map.of());
pqv.write(pqOut);

一些不太明显但重要的点

  • 同一个嵌入模型生成的向量分布一致,可复用 PQ codebook。
  • JVector 提供 ExplicitThreadLocal 解决 ThreadLocal 对象跨线程访问限制。
  • Fused ADC 仅支持 PQ(不支持 BQ)。
  • 构建过程中可能因内存带宽饱和导致变慢,JVector 使用 PhysicalCoreExecutor 优化线程数(默认核数一半,可配置 -Djvector.physical_core_count)。

高级特性

  • Fused ADC 与 InlineVectors 一样作为 Feature 支持增量构建。
  • Anisotropic PQ 支持但难以调参,需实验确定。
  • 图索引支持删除节点,并可通过 markNodeDeletedcleanup 移除。
  • 可使用 OnHeapGraphIndex::saveGraphIndexBuilder.load 进行快照与恢复。

相关研究


开发与测试

本项目为 多模块 Maven 项目,支持 Java 11 向下兼容构建,但可在 Java 20+ 上启用优化功能。

模块结构:

  • jvector-base:Java 11 基础实现
  • jvector-twenty:Java 20+ 扩展功能
  • jvector-multirelease:打包发布版本
  • jvector-examples:示例
  • jvector-tests:测试模块

运行测试:

mvn test                        # Java 20+ 测试
mvn -Pjdk11 test               # Java 11 测试

运行示例:

mvn compile exec:exec@sift     # 运行 SiftSmall 示例
mvn compile exec:exec@bench    # 运行基准测试

若仅需测试某个类或方法:

mvn -Dtest=TestClassName test
mvn -Dtest=TestClassName#methodName test

发布前需配置 Maven 仓库(OSSRH)并运行:

mvn -Prelease clean deploy

如果你想我帮你提炼要点、画流程图、总结原理或写中文教学文档也可以告诉我。

posted @ 2025-06-24 23:01  kuki'  阅读(45)  评论(0)    收藏  举报