如何在 Elasticsearch 中使用 BBQ 将 Jina v5 嵌入缩小 29 倍而不损失召回率

作者:来自 Elastic Jeffrey Rengifo

一项动手测试,对 Elasticsearch 中的 BBQ 与 float32 向量索引进行了比较,测量了五种语言下的内存占用、磁盘占用以及 recall@10。

立即通过 Search AI 的 自助式动手学习 亲自体验向量搜索。你可以立即开始 免费的云试用 ,或者在你的 本地机器 上体验 Elastic。


BBQ 量化Jina embeddings v5 向量在 Elasticsearch 中的内存占用缩小了 29 倍。与全精度 float32 基线相比, Recall@10 保持在 0.994。我们在一个包含五种语言的多语言新闻语料库上进行了这项测试,使用 jina-embeddings-v5-text-small 构建了一个原始 float32 索引,以及一个基于完全相同 向量bbq_hnsw 索引。随后,我们测量了两种索引的内存占用、磁盘占用以及检索质量。两种索引的磁盘占用几乎完全相同。真正决定你的集群是否能够容纳整个语料库的是内存占用,而在本次测试中,它从 12.71 MB 降低到了 0.44 MB。Jina v5 面向 量化 的训练正是召回率能够保持稳定的原因。

前提条件

  • Elasticsearch 9.x,并且已提供 jina-embeddings-v5-text-small 推理 端点

  • Python 3.10+。

  • Elasticsearch API 密钥。

什么是量化?

嵌入 是一组数字。默认情况下,每个数字都是一个 float32,占用 4 字节。量化 使用更少的位数来存储每个数字,以空间换取精度。

就像 JPEG 一样,经过 量化向量 是原始向量更小、保真度更低的副本,但仍然能够完成相同的任务。

JPEG 质量逐步降低(图片由 Michael Gäbler 提供, 通过 Wikimedia Commons,采用 CC BY 3.0 许可)。

名称 每维字节数 1024 维向量 压缩率
Float(基线) 4 4096 B 1x
int8 1 1024 B 4x
int4 0.5 512 B 8x
bbq ~0.14 142 B ~29x

什么是 BBQ?

Better Binary Quantization(BBQ)是 Elasticsearch 针对稠密向量提供的 1 位量化模式。向量的每个维度都存储为一个比特,再加上每个向量的少量校正字节。随后,在查询时会执行重新评分(rescore)步骤。这使最终的检索质量能够接近全精度搜索。

有关每一级量化背后的数学原理,请参阅《标量量化入门 101》《优化标量量化》以及《elasticstack.blog.csdn.net/article/det...》。

BBQ 如何保持搜索准确性?

普通的 1 位量化本身会导致搜索质量下降过多。BBQ 通过以下三种机制保持较高的检索质量:

  • 非对称精度:存储的向量每个维度仅使用 1 位。

  • 校正因子:每个向量使用少量浮点数记录舍入误差,并在评分时对距离进行校正。

  • 过采样与重新评分:BBQ 首先使用这些比特扫描候选结果,然后使用更高精度对排名靠前的结果重新排序。获取前 10 个结果时,通常会扫描约 30 个候选结果。

最终得到的是体积约缩小 32 倍的向量,同时检索质量仍然接近全精度。在本文下一部分中,我们将在真实语料库上测量内存节省情况以及召回率。

Jina embeddings v5 的工作原理

Jina 嵌入 v5 是一个支持 多语言嵌入 的模型,并采用了量化感知训练,因此它天然适合与 Elasticsearch 中的 BBQ 搭配使用:jina-embeddings-v5-text-small 生成的 1024 维向量高于二进制量化仍能保持准确性的维度下限,并且该模型经过训练,使得 1 位量化几乎不会损失检索质量。它的主要特性包括:

  • 一个模型支持多种任务 :v5 在单个基础模型之上使用小型 低秩适配(LoRA)适配器 ,每种任务对应一个适配器,包括:检索文本匹配聚类分类 。Elasticsearch 会在索引和查询时 自动选择合适的适配器

  • Matryoshka 维度:v5 经过训练,允许你对向量进行截断(1024、512 到 256 维),同时尽可能减少搜索质量下降。这是另一种缩小向量的方法,与量化相互独立。

  • 量化感知训练:v5 专门针对 BBQ 进行了训练,因此其 1 位向量几乎不会损失准确性。

本文使用的是 jina-embeddings-v5-text-small。该模型可通过 Elastic Inference ServiceEIS )使用,输出 1024 维向量,支持 32k 令牌 上下文窗口,并支持 93 种语言。这使它高于 384 维阈值 ,低于该阈值时,Elasticsearch 将不再默认使用 bbq_hnsw

完整的模型介绍请参阅 Search Labs 上的 Jina v5 文章

设置 BBQ 与 float32 对比

我们将创建两个索引:两者共享映射配置,变化的是 index_options.type 参数,该参数告诉 Elasticsearch 如何存储 稠密向量 字段(作为原始 float32 HNSW 或作为 1 位 BBQ):

索引 `index_options` 加载到内存
vectors-float32 hnsw 未经过量化的原始 float32(基线)
vectors-bbq bbq_hnsw 1 位 BBQ 量化 + 校正因子

然后,我们使用 Jina v5 对语料库进行一次嵌入处理,将相同的向量分别索引到两个索引中,并比较它们的磁盘占用、内存占用以及召回率。你可以通过完整的 支持博客内容笔记本 跟随操作。

连接到 Elasticsearch

ini 复制代码
`

1.  from elasticsearch import Elasticsearch, helpers

3.  es_client = Elasticsearch(
4.      ELASTICSEARCH_URL, api_key=ELASTICSEARCH_API_KEY, request_timeout=120
5.  )
6.  es_client.info()

`AI写代码

创建两个索引

bash 复制代码
`

1.  DIMS = 1024
2.  FLOAT_INDEX = "vectors-float32"
3.  BBQ_INDEX = "vectors-bbq"

6.  def create_index(name, index_options):
7.      if es_client.indices.exists(index=name):
8.          es_client.indices.delete(index=name)

10.      es_client.indices.create(
11.          index=name,
12.          mappings={
13.              "properties": {
14.                  "text": {"type": "text"},
15.                  "lang": {"type": "keyword"},
16.                  "embedding": {
17.                      "type": "dense_vector",
18.                      "dims": DIMS,
19.                      "index": True,
20.                      "similarity": "cosine",
21.                      "index_options": index_options,
22.                  },
23.              }
24.          },
25.      )

28.  create_index(FLOAT_INDEX, {"type": "hnsw"})       # raw float32 baseline
29.  create_index(BBQ_INDEX,   {"type": "bbq_hnsw"})   # 1-bit BBQ

`AI写代码![](https://csdnimg.cn/release/blogv2/dist/pc/img/runCode/icon-arrowwhite.png)

注意:在生产环境中,你可以使用 [semantic_text](https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/semantic-text "semantic_text") 让 Elasticsearch 自动管理映射和推理端点。

指向 Jina v5 推理端点

我们直接调用模型 jina-embeddings-v5-text-small(无需创建 推理端点)来将文本转换为向量。

ini 复制代码
`

1.  INFERENCE_ID = ".jina-embeddings-v5-text-small"

4.  def embed(texts, batch_size=16):
5.      out = []

7.      for i in range(0, len(texts), batch_size):
8.          batch = texts[i : i + batch_size]

10.          try:
11.              resp = es_client.inference.text_embedding(
12.                  inference_id=INFERENCE_ID, input=batch
13.              )
14.          except AttributeError:  # older client versions
15.              resp = es_client.inference.inference(inference_id=INFERENCE_ID, input=batch)
16.          out.extend(item["embedding"] for item in resp["text_embedding"])

18.      return np.array(out, dtype=np.float32)

21.  embed(["hello world"]).shape # testing

`AI写代码![](https://csdnimg.cn/release/blogv2/dist/pc/img/runCode/icon-arrowwhite.png)

作为测试结果,我们得到:

go 复制代码
`(1, 1024)`AI写代码

加载多语言新闻数据集

我们从 hotchpotch/multilingual_cc_news 流式读取真实新闻文章,这是 CC-News 的 parquet 镜像。我们从五种语言中获取大约 1,000 篇文章(总计约 3,000 个文档),另外保留一小部分标题集合作为搜索查询。使用多种语言也可以让 Jina v5 展示其多语言能力。

ini 复制代码
`

1.  from datasets import load_dataset

3.  LANGS = ["en", "de", "ja", "pt", "ru"]
4.  PER_LANG_DOCS = 1000
5.  PER_LANG_QUERIES = 20

7.  docs, queries = [], []
8.  for lang in LANGS:
9.      ds = load_dataset(
10.          "hotchpotch/multilingual_cc_news", lang, split="train", streaming=True
11.      )
12.      rows = [
13.          r
14.          for r in ds.take(PER_LANG_DOCS + PER_LANG_QUERIES)
15.          if r.get("maintext") and r.get("title")
16.      ]

18.      for row in rows[:PER_LANG_DOCS]:
19.          text = (row["title"] + ". " + row["maintext"]).replace("\n", " ").strip()
20.          docs.append({"text": text[:1000], "lang": lang})

22.      for row in rows[PER_LANG_DOCS:]:
23.          queries.append({"text": row["title"], "lang": lang})  # headlines as queries

25.  print(f"Corpus: {len(docs)} docs | Queries: {len(queries)}")

27.  # RES: Corpus: 3102 docs | Queries: 18

`AI写代码![](https://csdnimg.cn/release/blogv2/dist/pc/img/runCode/icon-arrowwhite.png)

生成嵌入并批量索引

我们只对语料库进行一次嵌入处理,然后将这些完全相同的向量写入两个索引中。

ini 复制代码
`

1.  doc_vectors = embed([d["text"] for d in docs])
2.  query_vectors = embed([q["text"] for q in queries])

`AI写代码
scss 复制代码
`

1.  def index_docs(name):
2.      actions = (
3.          {
4.              "_index": name,
5.              "_id": i,
6.              "_source": {
7.                  "text": d["text"],
8.                  "lang": d["lang"],
9.                  "embedding": doc_vectors[i].tolist(),
10.              },
11.          }
12.          for i, d in enumerate(docs)
13.      )
14.      helpers.bulk(es_client, actions, refresh=True)

17.  for name in (FLOAT_INDEX, BBQ_INDEX):
18.      index_docs(name)
19.      es_client.indices.forcemerge(index=name, max_num_segments=1)
20.      es_client.indices.refresh(index=name)

`AI写代码![](https://csdnimg.cn/release/blogv2/dist/pc/img/runCode/icon-arrowwhite.png)

我们执行 强制合并 到单个段,以确保存储数据稳定且具有可比性。

结果:磁盘占用与内存占用

磁盘使用 API( disk usage API 报告每个索引在向量(knn_vectors)上使用了多少字节。

ini 复制代码
`

1.  def vector_disk_bytes(name):
2.      du = es_client.indices.disk_usage(index=name, run_expensive_tasks=True)
3.      field = du[name]["fields"]["embedding"]
4.      knn = field.get("knn_vectors")
5.      if isinstance(knn, dict):
6.          return knn["size_in_bytes"]
7.      return field["knn_vectors_in_bytes"]

10.  float_disk = vector_disk_bytes(FLOAT_INDEX)
11.  bbq_disk = vector_disk_bytes(BBQ_INDEX)

13.  N = len(docs)
14.  float_mem = N * DIMS * 4
15.  bbq_mem = N * (DIMS // 8 + 14)

17.  print(f"On disk   -> float32: {float_disk/1e6:6.2f} MB | BBQ: {bbq_disk/1e6:6.2f} MB")
18.  print(f"In memory -> float32: {float_mem/1e6:6.2f} MB | BBQ: {bbq_mem/1e6:6.2f} MB  ({float_mem/bbq_mem:.0f}x smaller)")

`AI写代码![](https://csdnimg.cn/release/blogv2/dist/pc/img/runCode/icon-arrowwhite.png)

结果:

yaml 复制代码
`

1.  On disk   -> float32:  12.80 MB | BBQ:  13.25 MB
2.  In memory -> float32:  12.71 MB | BBQ:   0.44 MB  (29x smaller)

`AI写代码

在磁盘上,两个索引的大小基本相同。经过量化的索引仍然会保留原始 float32 向量(用于重新评分以及合并期间的重新量化),并在此基础上添加 1 位向量,因此 BBQ 最终在磁盘上的占用会略大一些。

真正的节省体现在内存中。HNSW 扫描只需要 RAM 中的 1 位向量,而原始浮点向量会从磁盘中读取,用于对顶部候选结果进行重新评分。我们使用文档中提供的 kNN 内存公式 来估算该占用:float 使用 num_vectors × dims × 4,而 bbq 使用 num_vectors × (dims/8 + 14)

BBQ 在磁盘上的额外字节数应该与我们为内存计算出的 1 位负载相匹配。在这里,计算结果为 13.25 - 12.80 = 0.45 MB,而计算得到的值为 0.44 MB。两者基本一致。

结果:召回率

为了检查量化索引返回的结果是否与 float 基线类似,我们使用召回率:

recall@k = | BBQ top-k ∩ float32 top-k | / k,并在所有查询上取平均值。

我们调整过采样因子(num_candidates / k),该因子表示 BBQ 在使用 1 位向量扫描候选结果后,在使用原始浮点向量对排名靠前的结果进行 重新排序 之前扫描的候选数量,以找到仍然能够匹配 float32 的最低值。

ini 复制代码
`

1.  def search_ids(index, qvec, k=10, num_candidates=10):
2.      resp = es_client.search(
3.          index=index,
4.          size=k,
5.          _source=False,
6.          knn={
7.              "field": "embedding",
8.              "query_vector": qvec.tolist(),
9.              "k": k,
10.              "num_candidates": num_candidates,
11.          },
12.      )

14.      return [h["_id"] for h in resp["hits"]["hits"]]

17.  K = 10

19.  # Ground truth: full-precision float32 with a wide candidate list (~exact)
20.  ground_truth = [
21.      set(search_ids(FLOAT_INDEX, qv, k=K, num_candidates=2000)) for qv in query_vectors
22.  ]

24.  oversamples = [1, 2, 3, 5, 10]
25.  recalls = []
26.  for f in oversamples:
27.      num_candidates = max(K * f, K)
28.      hits = 0
29.      for gt, qv in zip(ground_truth, query_vectors):
30.          got = set(search_ids(BBQ_INDEX, qv, k=K, num_candidates=num_candidates))
31.          hits += len(got & gt)
32.      recalls.append(hits / (len(query_vectors) * K))
33.      print(f"oversample {f:>2}x -> recall@{K} = {recalls[-1]:.3f}")

`AI写代码![](https://csdnimg.cn/release/blogv2/dist/pc/img/runCode/icon-arrowwhite.png)

结果如下:

BBQ 在 1x 过采样时的 recall@10 从 0.994 开始,在 3x 之前保持该水平,然后在更高的因子下稳定在 0.989,这意味着在所有过采样值下,它返回的 top-10 文档中至少有 98.9% 与 float32 相同。有关量化情况下召回率如何在不同数据集之间变化的更多信息,请参阅 快速与准确:测量量化向量搜索的召回率

BBQ 量化结果总结

相同的向量,两种存储格式,以及一个实验:

  • 磁盘: 大致相同(12.80 MB 对比 13.25 MB)。BBQ 为重新评分和合并保留了原始浮点向量。

  • 内存: 小 29 倍(12.71 MB 对比 0.44 MB)。这是决定你的集群是否能够容纳语料库的数字。

  • Recall@10: 在 1x 过采样时为 0.994。量化感知训练发挥了作用。

启用 BBQ 的条件:如果你的维度数量高于 384 维下限,如果你的向量是主要的内存开销,并且你能够接受额外的候选结果进行重新评分。那么可以启用 BBQ。对于 Jina v5 来说,该模型专门针对这一场景进行了训练,因此在大多数语料库上的召回率损失都很小。

关于 BBQ 和向量量化的进一步阅读

这篇内容对你有多大帮助?

原文:BBQ vector quantization for Jina embeddings v5 - Elasticsearch Labs

相关推荐
阿标在干嘛2 小时前
政策快报平台的全文检索:从ES到向量检索的演进
大数据·elasticsearch·全文检索
Elastic 中国社区官方博客13 小时前
如何比较两个 Elasticsearch 索引并找出缺失的文档
大数据·运维·数据库·elasticsearch·搜索引擎
阿里云大数据AI技术18 小时前
Elasticsearch 智能助手:Agent 让运维从经验驱动迈向智能协同
人工智能·elasticsearch·agent
贾天佑忆月1 天前
Mac 本地项目推送至 GitHub 完整指南
elasticsearch·macos·github
望江东浪1 天前
我的 Claude Code 效率工具全套配置分享
大数据·elasticsearch·搜索引擎
Elasticsearch2 天前
如何使用 OpenTelemetry 在 Elastic 上构建搜索分析,无需额外的管道
elasticsearch
Elasticsearch2 天前
用于 Kubernetes 监测的常用 ES|QL 查询
elasticsearch
本旺2 天前
Elasticsearch 前缀匹配性能优化实战,附4种方式即执行计划判别
大数据·elasticsearch·性能优化
Elastic 中国社区官方博客3 天前
Elastic 在 Everest Group 企业搜索产品 PEAK Matrix® 评估 2026 中被评为领导者
大数据·运维·人工智能·elasticsearch·搜索引擎·ai·全文检索