使用矢量引擎jVector构建索引和搜索向量教程

在本文中,我们简要介绍了jVector构建索引和搜索向量的核心功能。我们还研究了HNSW和HNANN结构如何在jVector中工作,以确保效率和正确性。
在人工智能和信息检索领域,经常需要在数据集中搜索相似向量。许多系统,如推荐系统、文本情感分析或文本生成,都使用向量搜索。
在本文中,我们将探索jVector库,以便从数据集和向量搜索中高效地构建索引。

了解矢量搜索
在信息检索的上下文中,我们可以将单词表示为向量,其中每个位置代表一个因子。这些因子表示所选参数和矢量化单词的可能性。例如,单词apple可以使用('fruit','blue')参数集向量化为[0.98,0.2],因为apple可能是水果,而不太可能是蓝色的。

在这样的系统中,我们可以通过距离函数找到与apple相似的单词。例如,单词banana可以表示为[0.94,0.1]。根据距离函数,单词apple和banana是相似的,因为它们的参数都很接近。

所有这些都可以归结为向量搜索的本质,即创建一种机制,以高置信度和高效率找到相似的向量。

jVector核心结构简介
高维问题和大数据集的向量搜索问题是经典向量搜索算法的一个挑战,由于维数灾难。因此,为了成为一个高效和可扩展的向量搜索处理器,jVector使用了两种现代结构:分层可导航小单词(HNSW)图和磁盘近似K最近邻(KNN)算法。

HNSW是一种图形结构,它通过使用层(或层次结构)和这些层之间的链接跳过搜索空间中不需要的向量来优化搜索:

Hierarchical Navigable Small Words (HNSW) data structure

层0包含搜索空间中的所有向量,这是最终搜索结果所在的位置。层1包含层0的一个小子集,具有比层0更多的聚类向量。最后,第2层包含第1层的一个子集,甚至比所有其他层更少和更多的聚类向量。层数取决于搜索空间中向量的数量和图的度。这些层是在索引结构构建过程中构建的,我们将在下面的部分中看到。

对于每一层,jVector执行一个CANN算法的实例来搜索该层中的相似向量。此外,HNSW创建长链接,连接两个不同层中的相同向量。因此,当ARNN完成处理层2时,即当找到局部最小值时,则从下一层中的相同向量(使用层之间的长链路)重新开始搜索,直到它到达层0。最后,当在最底层完成了搜索ANN时,搜索就完成了。

基本配置
构建jVector所需的唯一步骤是添加jVector Maven依赖项:

<dependency>
    <groupId>io.github.jbellis</groupId>
    <artifactId>jvector</artifactId>
    <version>4.0.0-rc.2</version>
</dependency>

jVector中基于图的索引
在本节中,我们将学习如何构建HNSW索引结构并将其持久化到磁盘上。

1.创建索引
让我们定义一个方法,使用之前加载的vector和文件路径持久化索引:

public static void persistIndex(List<VectorFloat<?>> baseVectors, Path indexPath) throws IOException {
    int originalDimension = baseVectors.get(0)
        .length();
    RandomAccessVectorValues vectorValues = new ListRandomAccessVectorValues(baseVectors, originalDimension);
    BuildScoreProvider scoreProvider = 
      BuildScoreProvider.randomAccessScoreProvider(vectorValues, VectorSimilarityFunction.EUCLIDEAN);
    try (GraphIndexBuilder builder = 
     new GraphIndexBuilder(scoreProvider, vectorValues.dimension(), 16, 100, 1.2f, 1.2f, true)) {
        OnHeapGraphIndex index = builder.build(vectorValues);
        OnDiskGraphIndex.write(index, vectorValues, indexPath);
    }
}

我们首先获取数据集的维数,并将维数存储在RandomizerVectorValues中,这本质上是一个有效的向量值列表包装器。

然后,我们定义一个BuildScoreProvider,作为计算距离的基础。在示例中,我们使用VectorSimilarityFunction类中提供的欧几里得距离计算器。

然后,我们使用try-with-resources从GraphIndexBuilder类打开一个AutoCloseable资源。它的一些参数会对索引的构建方式产生影响,直接影响向量搜索的质量和速度,所以让我们按顺序来看看它们:

  • scoreProvider:分数提供函数
  • dimension:向量的维数
  • M:图的最大度;如果我们将此值设置得较低,自然会构建更多的层
  • beamWidth:当ANN处理前K个最近邻时,波束搜索的大小
  • neighborOverflow:定义图的最大度可以在构建过程中暂时溢出此因子;必须大于1
  • alpha:用于控制解决方案多样性的CANN参数;必须为正值
  • addHierarchy:设置HNSW结构是否应该有层
然后,我们调用build()来基于构建器和数据集构建HNSW结构。最后,我们将索引写入作为persistIndex()方法参数提供的文件路径中。

2.测试索引创建
让我们使用一个简单的JUnit测试来说明客户端代码如何使用我们的persistIndex()方法:


class VectorSearchTest {
    private static Path indexPath;
    private static Map> datasetMap;
    @BeforeAll
    static void setup() throws IOException {
        datasetVectors = new VectorSearchTest().loadGlove6B50dDataSet(1000);
        indexPath = Files.createTempFile("sample", ".inline");
        persistIndex(new ArrayList<>(datasetVectors.values()), indexPath);
    }
    @Test
    void givenLoadedDataset_whenPersistingIndex_thenPersistIndexInDisk() throws IOException {
        try (ReaderSupplier readerSupplier = ReaderSupplierFactory.open(indexPath)) {
            GraphIndex index = OnDiskGraphIndex.load(readerSupplier);
            assertInstanceOf(OnDiskGraphIndex.class, index);
        }
    }
}
我们首先使用loadGlove 6 B50 dDataSet()辅助方法将数据集加载到单词到它们的向量表示的映射中,名为dataetMap。此外,我们创建一个Path来存储索引的输出,并将向量和路径传递给我们的persistIndex()方法。因此,在执行@BeforeAll注释方法之后,我们在磁盘上拥有了包含HNSW结构的索引。

最后,我们打开一个ReaderSupplier来读取磁盘上的索引,然后使用load()将其存储为对象。有了这些,我们可以验证索引是否被持久化,并使用所需的路径正确加载。

搜索相似向量
在本节中,我们将了解如何使用创建的磁盘索引搜索类似的向量。

为了演示向量搜索,让我们将一个新的测试方法添加到先前创建的测试类中,并将索引加载到磁盘中:


@Test
void givenLoadedDataset_whenSearchingSimilarVectors_thenReturnValidSearchResult() throws IOException {
    VectorFloat queryVector = datasetVectors.get("said");
    ArrayList vectorsList = new ArrayList<>(datasetVectors.values());
    try (ReaderSupplier readerSupplier = ReaderSupplierFactory.open(indexPath)) {
        GraphIndex index = OnDiskGraphIndex.load(readerSupplier);
        SearchResult result = GraphSearcher.search(queryVector, 10,
            new ListRandomAccessVectorValues(vectorsList, vectorsList.get(0).length()),
            VectorSimilarityFunction.EUCLIDEAN, index, Bits.ALL);
        assertNotNull(result.getNodes());
        assertEquals(10, result.getNodes().length);
    }
}
我们首先定义一个queryVector,用来搜索相似的单词。我们可以选择一个词的向量表示,我们已经填充了我们以前在我们的地图。

然后,我们打开持久化在磁盘上的索引资源,并将其加载到变量index中。

之后,我们使用GraphSearch中的search()方法来生成相似性搜索结果。该方法接受多个参数,因此让我们分别查看每个参数:

  • queryVector:查询向量
  • topK:我们想要的顶部相似向量的数量
  • vectors:数据集中的向量,包装为ListRandomValues VectorValues格式
  • similarityFunction:矢量距离计算器函数
  • graph:以前创建的索引
  • acceptOrds:一个变量,用于控制哪些向量是可接受的解决方案;将其设置为Bits.ALL表示图中的任何节点都是可接受的
执行搜索后,我们最终得到一个SearchResult对象,其中包含搜索结果向量。在我们的例子中,它是10个向量,因为我们把10作为topK。

此外,result变量还包含一些关于搜索的元数据信息,比如它访问了多少个节点。