使用OpenSearch协议进行向量检索

PolarSearch的向量检索功能的支持通过REST API对文本、图像等非结构化数据进行高效的相似度搜索,能够在海量数据中快速而精准地找到与查询目标最为相似的结果,从而有效提升应用的智能化水平

功能简介

向量检索,也称为相似性搜索,是一种通过比较向量之间的“距离”来查找最相似数据的技术,它与依赖精确关键词匹配的传统搜索有着本质区别。

它的核心思想是将现实世界中的文本、图像、音频等非结构化数据,通过深度学习模型(如LLM)转换为向量嵌入(Embedding) 的数值表示。这些多维向量能够捕捉数据的深层语义信息。

当您发起一次查询时,PolarSearch会将您的查询内容同样转换为向量,然后执行k-最近邻(k-Nearest Neighbor, k-NN)搜索。这是一种核心算法,其目标是在海量数据中找到与您的查询向量“距离”最近的k个向量。这里的k是一个由您定义的数字(例如,当k=5时,即代表查找最相似的5个结果)。最终,PolarSearch会返回这k个最相似的结果。

为实现高效检索,PolarSearch依赖两大核心组件:向量索引和向量存储优化。

  • 向量索引:为了避免在海量数据中进行全量计算,需要预先建立向量索引。索引能够根据向量数据的特征构建一种为查询而优化的数据结构,在查询时能够大幅缩小搜索范围,从而显著提升检索性能。PolarSearch内支持了多种类型的向量索引,以下为您主要介绍业界主流的HNSWIVF索引:

    • HNSW (Hierarchical Navigable Small World):一种基于图的索引,具有高性能和高召回率的优点,但内存开销也相应较大。适用于对查询延迟和精度要求极高,且数据集大小在内存容量范围内的场景。

    • IVF (Inverted File):一种基于聚类的倒排索引,内存占用较低,更适合需要处理超大规模数据集且内存受限的场景,但其搜索精度通常略低于HNSW。

  • 向量存储优化:向量数据,尤其是高维向量,会占用大量内存和存储空间。PolarSearch提供多种优化技术来降低资源消耗。

    • 向量量化:通过降低向量数值的精度来压缩数据,显著减少空间占用,是一种在压缩率和精度之间取得平衡的技术。PolarSearch支持乘积量化(PQ)、标量量化(SQ)和二值量化(BQ)。

    • 基于磁盘的存储:对于低内存环境,允许将部分索引数据存储在磁盘上,以较低的内存成本运行向量检索服务,代价是会适当增加查询延迟。

注意事项

在使用PolarSearch向量检索功能时,请您注意以下几点:

  • 索引训练要求IVF索引和PQ(乘积量化)技术在使用前需要一个独立的训练步骤。您需要提供一部分具有代表性的向量数据来训练模型,否则索引无法正常工作。

  • 内存开销HNSW索引虽然性能优异,但其图结构需要完全加载到内存中,会产生较高的内存开销。请在选择前评估您的集群内存资源。

  • 性能与成本权衡基于磁盘的向量搜索会适当增加查询延迟,请根据您的业务场景进行评估。

  • 自动训练:二值量化(BQ)的训练过程在索引构建期间自动处理,您无需进行额外的训练操作。

操作指南

准备工作

要使用REST API进行向量检索,您需要先开启智能搜索(PolarSearch)功能。请参考PolarSearch使用说明为已有集群或新建集群开启PolarSearch功能。

步骤一:创建向量索引

要存储和搜索向量,您需首先创建一个特定配置的索引。这主要包含两个关键动作:

  1. 启用k-NN并定义向量字段:在索引的设置(settings)中将knn参数设为true。这是一个主开关,它告知PolarDB该索引将用于向量检索。

    核心参数

    • engine:固定为faiss。

      说明

      Faiss (Facebook AI Similarity Search) 是一个由Meta AI开发的高性能开源库,专门用于高效的相似度搜索和海量向量数据聚类,PolarSearch使用Faiss作为其核心向量检索引擎。

    • dimension:用于指定向量的维度,需要与您模型产出的向量维度完全一致。

    • data_type:定义向量的数据类型。默认为float,您也可以选择bytebinary以优化存储。

    • space_type:定义向量相似度的计算方式(距离度量)。支持的范围如下:

      space_type

      距离度量

      说明

      l2

      L2(欧几里得距离)

      计算平方差和的平方根,对数值大小敏感。

      l1

      L1(曼哈顿距离)

      对向量各维度差值的绝对值求和。

      cosinesimil

      余弦相似度

      测量向量间的夹角,更关注方向而非大小。

      innerproduct

      内积

      计算向量点积,常用于排序场景。

      hamming

      汉明距离

      计算二进制向量中不同元素的数量。

      chebyshev

      L∞(切比雪夫距离)

      仅考虑向量各维度差值绝对值的最大值。

  2. 定义向量字段(HNSWIVF):在索引的映射(mappings)中,需定义一个knn_vector类型的字段。这个字段专门用于存储向量数据,并在此处配置向量的维度、相似度计算方式以及核心的索引方法。

    选型建议

    HNSWIVF在性能、资源消耗和精度上各有侧重,适用于不同的业务场景。您可以参考下表进行快速选型:

    对比维度

    HNSW

    IVF

    查询延迟

    极低。通过层级化的图结构快速定位,搜索路径短。

    较低。需要先定位到簇,再在簇内搜索,路径相对较长。

    召回率(精度)

    高。图的连接性更好,不容易漏掉近邻点。

    中到高。存在边缘效应(查询点在簇的边界),可能损失一定精度,可通过调整nprobes参数缓解。

    内存占用

    高。需要将完整的图结构加载到内存中。

    低。主要存储聚类中心和倒排列表,内存开销远低于HNSW。

    构建时间

    较长。构建高质量的图结构需要复杂的计算。

    较快。但需要一个额外的训练步骤来生成聚类中心。

    适用场景

    对查询性能和精度有极致要求,且内存资源充足的场景。例如:实时语义搜索、人脸识别。

    数据集规模巨大,内存资源受限,且可以接受微小精度损失的成本敏感型场景。例如:海量商品推荐、大规模图片库检索。

使用示例

HNSW

HNSW通过IndexHNSWFlat实现,适用于对性能和召回率有高要求的场景。

核心参数

参数

取值范围

说明

m

正整数。

图中每个节点的最大邻居(出度)数量。此值决定了图的密度,且是影响索引质量和内存占用的最关键参数。

  • 值越大:图的连接性越好,搜索路径更优,召回率更高。但同时索引构建更慢,内存占用也越大。

  • 值越小:构建速度快,内存占用小。但可能导致搜索过早陷入局部最优,影响召回率。

  • 实践建议:通常建议取值为864之间。可以从1632开始尝试,根据召回率和内存占用的测试结果进行调整。

ef_construction

正整数,且通常应大于m

构建索引时,动态邻居列表的大小。它控制了构建图期间的搜索深度和广度。此值主要影响索引的构建时间和最终质量。

  • 值越大:在插入新节点时能探索更多的潜在邻居,构建出的图质量更高(有利于召回率),但构建时间会显著增加。

  • 实践建议:通常建议设置为m2倍或更高。如果对构建时间不敏感但追求高质量索引,可以设置为500或更高。

ef_search

正整数。

查询时,动态邻居列表的大小。它控制了查询期间的搜索深度。

说明

此参数不在创建索引时指定,而是在查询时或在索引的settings中全局设置。它是影响查询延迟和召回率的直接因素。

  • 值越大:查询时会探索更多的节点,召回率更高,但查询耗时也越长。

  • 实践建议:此值没有固定选型建议,需要通过业务压测找到延迟和召回率的最佳平衡点。可以从一个较小的值(如50100)开始,逐步增加并观察性能变化。

说明

实际创建HNSW索引时,请将下述<my-index>替换为您的索引名称,<my_vector_field>替换为您的字段名称。同时,其他核心参数dimensiondata_typespace_typem以及ef_construction等参数请根据实际业务需求配置。

REST API

// HNSW索引创建示例,请将<my-index>替换为您的实际的索引名称
PUT /<my-index>
{
  "settings": {
    "index": { 
      "knn": true 
    } 
  },
  "mappings": {
    "properties": {
      "<my_vector_field>": {//请将<my_vector_field>替换为您的实际的字段名称
        "type": "knn_vector",
        "dimension": 128,
        "data_type": "float",
        "method": {
          "name": "hnsw",
          "engine": "faiss",
          "space_type": "l2",
          "parameters": {
            "m": 16,
            "ef_construction": 256
          }
        }
      }
    }
  }
}

Java client

private static void createVectorIndex(OpenSearchClient client) throws IOException {
    Property vectorProperty = Property.of(p -> p.knnVector(
        KnnVectorProperty.of(kvp -> kvp
            .dimension(128)
            .dataType("float")
            .method(new KnnVectorMethod.Builder()
                .name("hnsw")
                .engine("faiss")
                .spaceType("l2")
                .parameters(Map.of(
                    "m", JsonData.of(16),
                    "ef_construction", JsonData.of(256)
                ))
                .build()
            )
        )
    ));
    
    TypeMapping mapping = TypeMapping.of(m -> m
        .properties("<my_vector_field>", vectorProperty)
        .properties("text", Property.of(p -> p.text(TextProperty.of(t -> t))))
        .properties("category", Property.of(p -> p.keyword(k -> k)))
    );
    
    CreateIndexRequest request = new CreateIndexRequest.Builder()
        .index(<my-index>)
        .settings(s -> s.knn(true))
        .mappings(mapping)
        .build();
        
    client.indices().create(request);
}

IVF

IVF通过IndexIVFFlat实现,适用于内存受限的超大规模数据集场景。

核心参数

参数

取值范围

说明

nlist

正整数。

聚类中心的数量。索引会将整个向量空间划分为nlist个区域(簇)。此值是影响IVF性能的基础。

  • 值越大:划分的区域越精细,每个簇包含的向量越少,查询时需要扫描的数据量更少,速度更快。但可能增加边缘效应,导致召回率下降,同时内存占用也会增加。

  • 值越小:每个簇包含的向量多,搜索速度慢。但召回率可能更高。

  • 实践建议:一个常见的经验法则是将nlist设置在4 * sqrt(N)16 * sqrt(N)之间,其中N是向量总数。例如,对于100万个向量,sqrt(N) = 1000,那么nlist可以考虑设置为400016000之间。通常从10244096开始是一个不错的起点。

nprobes

正整数,且通常应小于nlist

查询时,需要搜索的聚类中心(簇)的数量。此值是在查询速度和召回率之间进行权衡的最直接参数。

  • 值越大:查询时会访问更多的簇,搜索范围更广,能有效缓解边缘效应,召回率更高;但查询速度会线性下降。

  • 值越小:查询速度快,但如果查询向量恰好落在多个簇的边界,很可能因为搜索范围不够大而找不到最近邻,导致召回率低。

  • 实践建议:通常从一个较小的值开始,如1020,然后根据对召回率的要求逐步增加,直到找到可接受的性能平衡点。

说明

实际创建IVF索引时,请将下述<my-index>替换为您的索引名称,<my_vector_field>替换为您的字段名称。同时,其他核心参数dimensiondata_typespace_typenlistnprobes等参数请根据实际业务需求配置。

// IVF索引创建示例,请将<my-index>替换为您的实际的索引名称
PUT /<my-index>
{
  "settings": {
    "index": { 
      "knn": true 
    } 
  },
  "mappings": {
    "properties": {
      "<my_vector_field>": {// 请将<my_vector_field>替换为您的实际的字段名称
        "type": "knn_vector",
        "dimension": 4,
        "data_type": "byte",
        "method": {
          "name": "ivf",
          "engine": "faiss",
          "space_type": "l2",
          "parameters": {
            "nlist": 1024,
            "nprobes": 10 // nprobes通常在查询时指定,此处为示例
          }
        }
      }
    }
  }
}

步骤二:索引向量数据

准备好您的文档,包括向量数据及其他元数据,然后将其索引到您刚刚创建的索引中。

REST API

POST /_bulk
{ "index": { "_index": "my-index", "_id": "doc_1" } }
{ "my_vector_field": [5.2, 4.4] }
{ "index": { "_index": "my-index", "_id": "doc_2" } }
{ "my_vector_field": [5.2, 3.9] }
{ "index": { "_index": "my-index", "_id": "doc_3" } }
{ "my_vector_field": [4.9, 3.4] }
{ "index": { "_index": "my-index", "_id": "doc_4" } }
{ "my_vector_field": [4.2, 4.6] }
{ "index": { "_index": "my-index", "_id": "doc_5" } }
{ "my_vector_field": [3.3, 4.5] }

Java client

private static void indexSampleData(OpenSearchClient client) throws IOException {
    List<Map<String, Object>> documents = new ArrayList<>();
    documents.add(Map.of("text", "a book about data science", "category", "books", "<my_vector_field>", List.of(1.0f, 2.0f, 3.0f, 4.0f)));
    documents.add(Map.of("text", "an intelligent smartphone with a great camera", "category", "electronics", "<my_vector_field>", List.of(8.0f, 7.0f, 6.0f, 5.0f)));
    documents.add(Map.of("text", "a technical manual for a smart device", "category", "electronics", "<my_vector_field>", List.of(3.0f, 4.0f, 5.0f, 6.0f)));

    for (int i = 0; i < documents.size(); i++) {
        IndexRequest<Map<String, Object>> request = new IndexRequest.Builder<Map<String, Object>>()
            .index(<my-index>)
            .id("doc_" + i)
            .document(documents.get(i))
            .build();
        client.index(request);
    }
}

步骤三:执行向量检索

现在您可以发起向量检索请求,从海量数据中找出与查询向量最相似的结果。

基本k-NN搜索

这是最基础的向量搜索,它会在整个索引中找出与查询向量距离最近的K个结果。

REST API

POST /<my-index>/_search
{
  "size": 3,
  "query": {
    "knn": {
      "<my_vector_field>": {
        "vector": [3.1, 4.1, 5.1, 6.1],
        "k": 3
      }
    }
  }
}

Java client

// 准备您的查询向量
List<Float> queryVector = List.of(3.1f, 4.1f, 5.1f, 6.1f);

private static void performBasicKnnSearch(OpenSearchClient client, List<Float> queryVector) throws IOException {
    System.out.println("\n--- 1. Performing Basic k-NN Search ---");
    System.out.println("Querying for vectors most similar to: " + queryVector);
    // 查找最相似的3个结果
    int k = 3;

    KnnQuery knnQuery = new KnnQuery.Builder()
        .field("<my_vector_field>")
        .vector(queryVector)
        .k(k)
        .build();

    SearchRequest searchRequest = new SearchRequest.Builder().index(<my-index>).query(new Query.Builder().knn(knnQuery).build()).size(k).build();
    SearchResponse<Map> response = client.search(searchRequest, Map.class);

    printResults(response);
}

带过滤条件的k-NN搜索(混合搜索)

在许多场景下,您需要在执行向量搜索前,先通过一个或多个条件缩小搜索范围。这正是混合搜索的核心思想。您可以在KnnQuery中使用filter子句来实现这一点,过滤器本身可以是任何标准的OpenSearch查询,如term(精确值匹配)或match(全文检索)。

使用文本匹配过滤

这适用于经典的“关键字+向量”混合搜索场景。例如,先搜索所有描述中包含“新款手机”的文档,然后根据向量相似度对它们进行排序。

REST API

POST /<my-index>/_search
{
  "size": 3,
  "query": {
    "knn": {
      "<my_vector_field>": {
        "vector": [3.1, 4.1, 5.1, 6.1],
        "k": 3,
        "filter": {
          "match": {
            "text": "book"
          }
        }
      }
    }
  }
}

Java client

// 准备您的查询向量
List<Float> queryVector = List.of(3.1f, 4.1f, 5.1f, 6.1f);

private static void performHybridSearchWithText(OpenSearchClient client, List<Float> queryVector) throws IOException {
    System.out.println("\n--- 2. Performing Hybrid Search (k-NN + Text Match) ---");
    // 准备您的查询关键字
    String textQuery = "book";
    System.out.println("Filtering for documents containing '" + textQuery + "', then finding most similar vectors.");
    // 查找最相似的3个结果
    int k = 3;

    MatchQuery matchQuery = new MatchQuery.Builder().field("text").query(q -> q.stringValue(textQuery)).build();

    KnnQuery hybridKnnQuery = new KnnQuery.Builder()
        .field("<my_vector_field>")
        .vector(queryVector)
        .k(k)
        .filter(new Query.Builder().match(matchQuery).build())
        .build();
    
    SearchRequest searchRequest = new SearchRequest.Builder().index(<my-index>).query(new Query.Builder().knn(hybridKnnQuery).build()).size(k).build();
    SearchResponse<Map> response = client.search(searchRequest, Map.class);
    
    printResults(response);
}

使用精确值过滤(Term Filter)

这适用于根据明确的标签、分类或ID进行过滤的场景。例如,只在“电子产品”类别中搜索最相似的商品。

REST API

POST /<my-index>/_search
{
  "size": 3,
  "query": {
    "knn": {
      "<my_vector_field>": {
        "vector": [5, 4],
        "k": 3,
        "filter": {
          "term": {
            "category": "electronics"
          }
        }
      }
    }
  }
}

Java client

// 准备您的查询向量
List<Float> queryVector = List.of(3.1f, 4.1f, 5.1f, 6.1f);

private static void performFilteredSearchWithTerm(OpenSearchClient client, List<Float> queryVector) throws IOException {
    System.out.println("\n--- 3. Performing Filtered Search (k-NN + Term Filter) ---");
    // 准备您的查询分类
    String categoryFilter = "electronics";
    System.out.println("Filtering for documents in category '" + categoryFilter + "', then finding most similar vectors.");
    // 查找最相似的3个结果
    int k = 3;

    TermQuery termQuery = new TermQuery.Builder().field("category").value(v -> v.stringValue(categoryFilter)).build();
    
    KnnQuery filteredKnnQuery = new KnnQuery.Builder()
        .field("<my_vector_field>")
        .vector(queryVector)
        .k(k)
        .filter(new Query.Builder().term(termQuery).build())
        .build();
    
    SearchRequest searchRequest = new SearchRequest.Builder().index(<my-index>).query(new Query.Builder().knn(filteredKnnQuery).build()).size(k).build();
    SearchResponse<Map> response = client.search(searchRequest, Map.class);

    printResults(response);
}

配置存储优化

向量数据,尤其是高维浮点型向量,会占用大量内存。PolarSearch提供多种存储优化技术,通过对向量进行压缩(量化)或改变存储介质,在内存成本、查询性能和搜索精度之间取得平衡。

选型建议

在选择具体的优化策略前,您可以参考下表,快速找到最适合您业务场景的方案。

优化策略

压缩率

精度影响

训练要求

CPU开销

适用场景

标量量化 (SQ)

低 (固定2倍)

极小

无需训练

对搜索精度要求极高,希望在几乎不损失精度的前提下,获得适度内存优化的场景。

二值量化 (BQ)

高 (8-32倍)

较大

无需训练

中等

对内存极度敏感,可以接受一定(甚至较大)精度损失,以换取最大程度内存节省的场景。

乘积量化 (PQ)

最高

中等

需要训练

中等

数据集巨大,需要极致的压缩率,且愿意投入时间进行模型训练以平衡精度和内存的场景。

基于磁盘的向量存储

-

较大

无需训练

较高

内存资源极其有限,宁愿牺牲查询延迟(因磁盘I/O),也要将内存占用降至最低的成本敏感型场景。

操作说明

标量量化(SQ)

  • 工作原理:将标准的32位浮点(float)向量转换为16位浮点(fp16)向量进行存储,使内存占用直接减半。在计算距离时,会解码回32位进行,因此对精度的影响非常小。

  • 内存估算

    • 公式:内存 (GB) ≈ 1.1 * (2 * dimension + 8 * m) * num_vectors / 1024^3

    • 参数详解:

      • dimension:向量的维度。

      • m:HNSW索引中的m参数,即每个节点的最大邻居数。

      • num_vectors:向量总数。

      • 1.1:约10%的系统开销系数。

    • 示例:假设您有 100 万个向量,每个向量的维度为 256,每个向量的维度m为 16。内存需求可以估算如下:1.1 * (2 * 256 + 8 * 16) * 1,000,000 ~= 0.656 GB

  • 使用示例

    // HNSW + 标量量化(SQ)示例
    PUT /<my-sq-index>
    {
      "settings": {
        "index": { 
          "knn": true 
        }
      },
      "mappings": {
        "properties": {
          "<my_vector_field>": {
            "type": "knn_vector",
            "dimension": 128,
            "method": {
              "name": "hnsw",
              "engine": "faiss",
              "parameters": {
                "m": 16,
                "ef_construction": 256,
                "encoder": {// 启用SQ
                  "name": "fp16"
                 } 
              }
            }
          }
        }
      }
    }
    

二值量化(BQ)

  • 工作原理:将浮点向量的每个维度压缩为二进制位(01)进行存储,从而实现极高的压缩率。训练过程在索引构建时自动完成。

  • 内存估算

    • 公式:内存 (GB) ≈ 1.1 * ((dimension * bits / 8) + 8 * m) * num_vectors / 1024^3

    • 参数详解:

      • dimension:向量的维度。

      • bits:每个维度用多少个二进制位表示,可选值为1,2,4。bits越小,压缩率越高,但精度损失越大。

      • m:HNSW索引中的m参数。

      • num_vectors:向量总数。

    • 示例:假设您有100万个向量,每个向量的维度为256,每个向量的维度m16。以下部分提供了各种压缩值对内存需求的估算。

      • 1位量化(32倍压缩):在1位量化中,每个维度用1位表示,相当于32倍压缩系数。内存需求可以估算如下:1.1 * ((256 * 1 / 8) + 8 * 16) * 1,000,000 ~= 0.176 GB

      • 2位量化(16倍压缩):在2位量化中,每个维度用2位表示,相当于16倍压缩系数。内存需求可以估算如下:1.1 * ((256 * 2 / 8) + 8 * 16) * 1,000,000 ~= 0.211 GB

  • 使用示例

    // HNSW + 二值量化(BQ)示例
    PUT /<my-bq-index>
    {
      "settings" : { 
        "index": { 
          "knn": true 
        } 
      },
      "mappings": {
        "properties": {
          "<my_vector_field>": {
            "type": "knn_vector",
            "dimension": 128,
            "method": {
                "name": "hnsw",
                "engine": "faiss",
                "parameters": {
                  "m": 16,
                  "ef_construction": 512,
                  "encoder": {
                    "name": "binary",
                    "parameters": {// 启用BQ,使用1位量化
                      "bits": 1 
                    }
                  }
                }
            }
          }
        }
      }
    }
    

乘积量化(PQ)

PQ是一种先进的向量压缩技术,它能实现比SQBQ更高的压缩率,但代价是需要一个独立的训练步骤来构建压缩模型。

  • 工作原理

    1. 向量切分:首先,将一个原始的高维向量(例如256维)切分为m个等长的低维子向量。例如,将256维向量按m=32切分,会得到328维的子向量。

    2. 码本训练:接着,系统会为每一个子向量空间独立学习一个“码本”(Codebook)。这个码本包含2^code_size个中心点(也称质心)。这个训练过程通常使用K-均值聚类算法完成。

    3. 量化编码:训练完成后,在对新向量进行编码时,其每个子向量不再存储原始的浮点值,而是被替换为该子向量空间码本中距离它最近的那个中心点的ID。如果code_size8,则ID范围是0-255,正好用1个字节存储。

    4. 最终结果:一个原始向量就被转换成了一组中心点ID的序列,从而实现了极高的压缩。

  • 训练要求:PQ的性能严重依赖于训练数据的质量。您必须提供一组与您最终要检索的数据分布相似的向量来进行训练。

    • 训练数据来源:可以是您要索引的向量数据的子集。

    • 建议的训练数据量:

      • 结合HNSW使用时:建议训练向量数量为 2^code_size * 1000

      • 结合IVF使用时:建议训练向量数量为 max(1000 * nlist, 2^code_size * 1000)

  • 内存估算:以HNSW+PQ为例。因为当HNSWPQ结合使用时,其内存计算公式较为复杂,因为它包含了压缩向量、HNSW图结构和PQ码本三部分的开销。

    • 公式:内存 (字节) ≈ 1.1 * ( (per_vector_cost) * num_vectors + (codebook_cost) )

      • per_vector_cost = (pq_code_size / 8 * pq_m) + 24 + (8 * hnsw_m)

      • codebook_cost = num_segments * (2^pq_code_size) * 4 * dimension

    • 参数详解:

      • num_vectors:向量总数。

      • dimension:原始向量的维度。

      • pq_m:向量切分的段数。dimension必须能被pq_m整除。

      • pq_code_size:每个子向量码本的大小,以比特为单位。通常为8。

      • hnsw_m:HNSW索引中的m参数,即每个节点的最大邻居数。

      • num_segments:一个底层技术参数,代表索引被分成的段数。在估算时可以按集群分片数或一个保守值(如100)来计算。

      • 1.1:约10%的系统开销系数。

      • 248:HNSW图结构中每个节点的固定开销和指针开销。

      • 4:代表码本中的中心点坐标使用32位浮点数(4字节)存储。

    • 示例:假设您有100万个向量(num_vectors),每个向量的维度(dimension)为256,每个向量切分段数(pq_m)为32,每个子向量码本的大小(pq_code_size)为8,HNSW索引的m参数为16,num_segments100。

      1. 计算单个向量的开销(per_vector_cost):

        1. 压缩后向量大小 = pq_code_size / 8 * pq_m = 8 / 8 * 32 = 32字节。

        2. HNSW图开销 = 24 + 8 * hnsw_m = 24 + 8 * 16 = 152字节。

        3. per_vector_cost = 32 + 152 = 184字节

      2. 计算码本的总开销(codebook_cost):

        1. codebook_cost = num_segments * (2^pq_code_size) * 4 * dimension。

        2. codebook_cost = 100 * (2^8) * 4 * 256 = 100 * 256 * 4 * 256 = 26,214,400字节。

      3. 计算总内存:

        1. 总内存 ≈ 1.1 * (per_vector_cost * num_vectors + codebook_cost)

        2. 总内存 ≈ 1.1 * (184 * 1,000,000 + 26,214,400) ≈ 231,235,840 字节 ≈ 0.215 GB

  • 使用示例

    // HNSW + 乘积量化(PQ)示例
    PUT /<my-hnswpq-index>
    {
      "settings" : { 
        "index": { 
          "knn": true 
        } 
      },
      "mappings": {
        "properties": {
          "<my_vector_field>": {
            "type": "knn_vector",
            "dimension": 128, // 维度必须能被 m 整除
            "method": {
                "name": "hnsw",
                "engine": "faiss",
                "parameters": {
                  "m": 16, // HNSWm参数
                  "ef_construction": 512,
                  "encoder": {
                    "name": "pq",
                    "parameters": {
                      "m": 4, // PQm参数:将128维切为432维
                      "code_size": 8
                    }
                  }
                }
            }
          }
        }
      }
    }

基于磁盘的向量存储

  • 工作原理:基于磁盘的向量搜索是利用内部的量化技术压缩向量,并将主要的图结构存储在磁盘上,而不是堆内存中。这种内存优化可以大幅节省内存,但搜索延迟会略有增加,同时仍能保持较高的召回率。

  • 内存估算:无固定公式。实际物理内存占用由操作系统根据访问模式动态管理。

  • 使用示例

    // 基于磁盘存储的示例
    PUT /<my-ondisk-index>
    {
      "settings" : { 
        "index": {
           "knn": true 
        } 
      },
      "mappings": {
        "properties": {
          "<my_vector_field>": {
            "type": "knn_vector",
            "dimension": 128,
            "mode": "on_disk" // 启用基于磁盘的模式
          }
        }
      }
    }
    

附录:完整示例代码

下面是一个完整的Java client示例代码,演示了从创建向量索引到执行向量检索的全过程。

依赖配置(pom.xml)

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.example</groupId>
    <artifactId>vector-search-demo</artifactId>
    <version>1.0-SNAPSHOT</version>

    <properties>
        <maven.compiler.source>11</maven.compiler.source>
        <maven.compiler.target>11</maven.compiler.target>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.opensearch.client</groupId>
            <artifactId>opensearch-java</artifactId>
            <version>3.0.0</version>
        </dependency>
        <dependency>
            <groupId>org.apache.httpcomponents.client5</groupId>
            <artifactId>httpclient5</artifactId>
            <version>5.3</version>
        </dependency>
        <dependency>
            <groupId>org.apache.httpcomponents.core5</groupId>
            <artifactId>httpcore5</artifactId>
            <version>5.3</version>
        </dependency>
        <dependency>
            <groupId>org.apache.httpcomponents.core5</groupId>
            <artifactId>httpcore5-h2</artifactId>
            <version>5.3</version>
        </dependency>
        <!-- Jackson databind is needed by opensearch-java -->
        <dependency>
            <groupId>com.fasterxml.jackson.core</groupId>
            <artifactId>jackson-databind</artifactId>
            <version>2.16.1</version>
        </dependency>
    </dependencies>
</project>

示例程序(VectorSearchDemo.java)

package com.example;

import org.apache.hc.client5.http.auth.AuthScope;
import org.apache.hc.client5.http.auth.UsernamePasswordCredentials;
import org.apache.hc.client5.http.impl.auth.BasicCredentialsProvider;
import org.apache.hc.client5.http.impl.nio.PoolingAsyncClientConnectionManagerBuilder;
import org.apache.hc.core5.http.HttpHost;
import org.opensearch.client.opensearch.OpenSearchClient;
import org.opensearch.client.opensearch._types.mapping.KeywordProperty;
import org.opensearch.client.opensearch._types.mapping.KnnVectorMethod;
import org.opensearch.client.opensearch._types.mapping.KnnVectorProperty;
import org.opensearch.client.opensearch._types.mapping.Property;
import org.opensearch.client.opensearch._types.mapping.TextProperty;
import org.opensearch.client.opensearch._types.mapping.TypeMapping;
import org.opensearch.client.opensearch._types.query_dsl.KnnQuery;
import org.opensearch.client.opensearch._types.query_dsl.MatchQuery;
import org.opensearch.client.opensearch._types.query_dsl.Query;
import org.opensearch.client.opensearch._types.query_dsl.TermQuery;
import org.opensearch.client.opensearch.core.IndexRequest;
import org.opensearch.client.opensearch.core.SearchRequest;
import org.opensearch.client.opensearch.core.SearchResponse;
import org.opensearch.client.opensearch.core.search.Hit;
import org.opensearch.client.opensearch.indices.CreateIndexRequest;
import org.opensearch.client.opensearch.indices.DeleteIndexRequest;
import org.opensearch.client.transport.OpenSearchTransport;
import org.opensearch.client.transport.httpclient5.ApacheHttpClient5TransportBuilder;
import org.opensearch.client.json.JsonData;
import org.opensearch.client.json.jackson.JacksonJsonpMapper;

import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;

public class VectorSearchDemo {

    private static final String INDEX_NAME = "test-java-demo-full-search";
    private static final String FIELD_NAME = "test-embedding";
    private static final int VECTOR_DIMENSION = 4;

    public static void main(String[] args) throws IOException {
        OpenSearchClient client = createClient("<polarsearch_host>", <polarsearch_port>, "<polarsearch_username>", "<polarsearch_password>");
        System.out.println("Client initialized.");

        deleteIndexIfExists(client);
        createVectorIndex(client);
        System.out.println("Index '" + INDEX_NAME + "' created.");

        indexSampleData(client);
        System.out.println("Sample data indexed.");
        
        try {
            Thread.sleep(2000);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }

        List<Float> queryVector = List.of(3.1f, 4.1f, 5.1f, 6.1f);

        // --- 依次执行三种搜索 ---
        performBasicKnnSearch(client, queryVector);
        performHybridSearchWithText(client, queryVector);
        performFilteredSearchWithTerm(client, queryVector);
        
        client._transport().close();
        System.out.println("\nClient closed.");
    }

    // 初始化客户端
    private static OpenSearchClient createClient(String hostName, int port, String username, String password) {
        final var host = new HttpHost("http", hostName, port);
        final var credentialsProvider = new BasicCredentialsProvider();
        credentialsProvider.setCredentials(new AuthScope(host), new UsernamePasswordCredentials(username, password.toCharArray()));
        final var connectionManager = PoolingAsyncClientConnectionManagerBuilder.create().build();
        OpenSearchTransport transport = ApacheHttpClient5TransportBuilder.builder(host)
            .setMapper(new JacksonJsonpMapper())
            .setHttpClientConfigCallback(httpClientBuilder ->
                httpClientBuilder.setDefaultCredentialsProvider(credentialsProvider).setConnectionManager(connectionManager)
            ).build();
        return new OpenSearchClient(transport);
    }

    // 如果索引存在则删除,便于测试效果。
    private static void deleteIndexIfExists(OpenSearchClient client) throws IOException {
        if (client.indices().exists(r -> r.index(INDEX_NAME)).value()) {
            client.indices().delete(new DeleteIndexRequest.Builder().index(INDEX_NAME).build());
            System.out.println("Index '" + INDEX_NAME + "' deleted.");
        }
    }

    // 创建向量索引
    private static void createVectorIndex(OpenSearchClient client) throws IOException {
        Property vectorProperty = Property.of(p -> p.knnVector(
            KnnVectorProperty.of(kvp -> kvp
                .dimension(VECTOR_DIMENSION)
                .dataType("float")
                .method(new KnnVectorMethod.Builder()
                    .name("hnsw")
                    .engine("faiss")
                    .spaceType("l2")
                    .parameters(Map.of(
                        "m", JsonData.of(16),
                        "ef_construction", JsonData.of(256)
                    ))
                    .build()
                )
            )
        ));
        
        TypeMapping mapping = TypeMapping.of(m -> m
            .properties(FIELD_NAME, vectorProperty)
            .properties("text", Property.of(p -> p.text(TextProperty.of(t -> t))))
            .properties("category", Property.of(p -> p.keyword(k -> k))) // 新增 category 字段
        );
        
        CreateIndexRequest request = new CreateIndexRequest.Builder()
            .index(INDEX_NAME)
            .settings(s -> s.knn(true))
            .mappings(mapping)
            .build();
            
        client.indices().create(request);
    }

    // 索引向量数据
    private static void indexSampleData(OpenSearchClient client) throws IOException {
        List<Map<String, Object>> documents = new ArrayList<>();
        documents.add(Map.of("text", "a book about data science", "category", "books", FIELD_NAME, List.of(1.0f, 2.0f, 3.0f, 4.0f)));
        documents.add(Map.of("text", "an intelligent smartphone with a great camera", "category", "electronics", FIELD_NAME, List.of(8.0f, 7.0f, 6.0f, 5.0f)));
        documents.add(Map.of("text", "a technical manual for a smart device", "category", "electronics", FIELD_NAME, List.of(3.0f, 4.0f, 5.0f, 6.0f)));

        for (int i = 0; i < documents.size(); i++) {
            IndexRequest<Map<String, Object>> request = new IndexRequest.Builder<Map<String, Object>>()
                .index(INDEX_NAME)
                .id("doc_" + i)
                .document(documents.get(i))
                .build();
            client.index(request);
        }
    }

    /**
     * 示例 1: 基础 k-NN 搜索
     */
    private static void performBasicKnnSearch(OpenSearchClient client, List<Float> queryVector) throws IOException {
        System.out.println("\n--- 1. Performing Basic k-NN Search ---");
        System.out.println("Querying for vectors most similar to: " + queryVector);
        int k = 3;

        KnnQuery knnQuery = new KnnQuery.Builder()
            .field(FIELD_NAME)
            .vector(queryVector)
            .k(k)
            .build();

        SearchRequest searchRequest = new SearchRequest.Builder().index(INDEX_NAME).query(new Query.Builder().knn(knnQuery).build()).size(k).build();
        SearchResponse<Map> response = client.search(searchRequest, Map.class);

        printResults(response);
    }

    /**
     * 示例 2: 混合搜索 (k-NN + 文本匹配)
     */
    private static void performHybridSearchWithText(OpenSearchClient client, List<Float> queryVector) throws IOException {
        System.out.println("\n--- 2. Performing Hybrid Search (k-NN + Text Match) ---");
        String textQuery = "book";
        System.out.println("Filtering for documents containing '" + textQuery + "', then finding most similar vectors.");
        int k = 3;

        MatchQuery matchQuery = new MatchQuery.Builder().field("text").query(q -> q.stringValue(textQuery)).build();

        KnnQuery hybridKnnQuery = new KnnQuery.Builder()
            .field(FIELD_NAME)
            .vector(queryVector)
            .k(k)
            .filter(new Query.Builder().match(matchQuery).build())
            .build();
        
        SearchRequest searchRequest = new SearchRequest.Builder().index(INDEX_NAME).query(new Query.Builder().knn(hybridKnnQuery).build()).size(k).build();
        SearchResponse<Map> response = client.search(searchRequest, Map.class);
        
        printResults(response);
    }

    /**
     * 示例 3: 带过滤条件的搜索 (k-NN + 精确值匹配)
     */
    private static void performFilteredSearchWithTerm(OpenSearchClient client, List<Float> queryVector) throws IOException {
        System.out.println("\n--- 3. Performing Filtered Search (k-NN + Term Filter) ---");
        String categoryFilter = "electronics";
        System.out.println("Filtering for documents in category '" + categoryFilter + "', then finding most similar vectors.");
        int k = 3;

        TermQuery termQuery = new TermQuery.Builder().field("category").value(v -> v.stringValue(categoryFilter)).build();
        
        KnnQuery filteredKnnQuery = new KnnQuery.Builder()
            .field(FIELD_NAME)
            .vector(queryVector)
            .k(k)
            .filter(new Query.Builder().term(termQuery).build())
            .build();
        
        SearchRequest searchRequest = new SearchRequest.Builder().index(INDEX_NAME).query(new Query.Builder().knn(filteredKnnQuery).build()).size(k).build();
        SearchResponse<Map> response = client.search(searchRequest, Map.class);

        printResults(response);
    }

    private static void printResults(SearchResponse<Map> response) {
        System.out.println("Search Results:");
        for (Hit<Map> hit : response.hits().hits()) {
            System.out.printf(" - ID: %s, Score: %.4f, Source: %s%n", hit.id(), hit.score(), hit.source());
        }
        if (response.hits().hits().isEmpty()) {
            System.out.println(" - No results found.");
        }
    }
}

运行方式

mvn clean package
mvn exec:java -Dexec.mainClass="com.example.VectorSearchDemo"