作者:来自 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 Service (EIS )使用,输出 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写代码
注意:在生产环境中,你可以使用
[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写代码
作为测试结果,我们得到:
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写代码
生成嵌入并批量索引
我们只对语料库进行一次嵌入处理,然后将这些完全相同的向量写入两个索引中。
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写代码
我们执行 强制合并 到单个段,以确保存储数据稳定且具有可比性。
结果:磁盘占用与内存占用
磁盘使用 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写代码
结果:
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写代码
结果如下:


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 背后数学原理的信息,请参阅 Lucene 和 Elasticsearch 中的 Better Binary Quantization。
-
有关 Jina v5 架构的更多信息,请参阅 Search Labs 上的 Jina embeddings v5。
-
有关采用 BBQ 的更广泛介绍,请参阅 如何将 BBQ 实现到你的使用场景中。
-
有关 BBQ 原始研究,请参阅论文 RaBitQ:具有理论误差界限的高维向量量化,用于近似最近邻搜索。
这篇内容对你有多大帮助?
原文:BBQ vector quantization for Jina embeddings v5 - Elasticsearch Labs