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 的 量化 感知训练是召回率得以保持的关键。
jina-embeddings-v5-text-small 推理 端点。embedding 是一个数字列表。默认情况下,每个数字都是一个 float32 类型,占用 4 字节。量化 则使用更少的比特来存储每个数字,以牺牲精度来换取空间。
就像 JPEG 图像一样,量化向量 是原始向量的一个更小、保真度更低的副本,但仍然能够完成任务。

JPEG 质量的逐渐下降(图片由 Michael Gäbler 提供,CC BY 3.0 经由 Wikimedia Commons)。
名称 | 每维度字节数 | 1024 维向量 | 压缩比 |
|---|---|---|---|
| 4 | 4096 B | 1x |
| 1 | 1024 B | 4x |
| 0.5 | 512 B | 8x |
| ~0.14 | 142 B | ~29x |
Better Binary Quantization (BBQ) 是 Elasticsearch 针对稠密向量的 1 比特量化模式。向量的每个维度都以单个比特存储,每个向量额外增加几个纠正字节。然后,在查询时会应用一个重新评分步骤。这使得最终的检索质量接近于全精度搜索。
关于每个量化级别的数学原理,请参阅 Scalar quantization 101、Optimized Scalar Quantization 和 BBQ 深度解析。
单纯的 1 比特量化本身会导致搜索质量显著下降。BBQ 通过以下三种机制保持了高检索质量:
最终,向量体积缩小了约 32 倍,同时检索质量接近于全精度。在文章的下一节中,我们将在一个真实的语料库上测量内存节省和召回率。
Jina embeddings v5 是一个多语言 embedding 模型,采用了量化感知训练,这使其与 Elasticsearch 中的 BBQ 天然契合:jina-embeddings-v5-text-small 模型产生的 1024 维向量超过了二值量化保持准确性的维度下限,并且该模型经过训练,使得 1 比特量化只会损失很小的质量。其主要特点包括:
我们使用的是 jina-embeddings-v5-text-small 模型。该模型通过 Elastic Inference Service (EIS) 提供,输出 1024 维向量,支持 32k token 上下文,并支持 93 种语言。这使其超过了 384 维阈值,在此阈值以下,Elasticsearch 默认不再使用 bbq_hnsw。
完整的模型详情请参阅 Search Labs 上的 Jina v5 文章。
我们将创建两个索引:两者共享映射,不同之处在于 index_options.type 参数,它告诉 Elasticsearch 如何存储稠密向量字段(是原始的 float32 HNSW 还是 1 比特 BBQ):
索引 |
| 载入内存 |
|---|---|---|
|
| 未经量化的原始 |
|
| 1 比特 BBQ 量化 + 纠正因子 |
然后,我们使用 Jina v5 对语料库进行一次 embedding,将这些完全相同的向量索引到这两个索引中,并比较它们的磁盘使用情况、内存占用和召回率。您可以按照完整的 配套博客内容 Jupyter Notebook 进行操作。
from elasticsearch import Elasticsearch, helpers
# 假设 ELASTICSEARCH_URL 和 ELASTICSEARCH_API_KEY 已经定义
es_client = Elasticsearch(
ELASTICSEARCH_URL,
api_key=ELASTICSEARCH_API_KEY,
request_timeout=120
)
es_client.info()DIMS = 1024
FLOAT_INDEX = "vectors-float32"
BBQ_INDEX = "vectors-bbq"
def create_index(name, index_options):
if es_client.indices.exists(index=name):
es_client.indices.delete(index=name)
es_client.indices.create(
index=name,
mappings={
"properties": {
"text": {"type": "text"},
"lang": {"type": "keyword"},
"embedding": {
"type": "dense_vector",
"dims": DIMS,
"index": True,
"similarity": "cosine",
"index_options": index_options,
},
}
},
)
create_index(FLOAT_INDEX, {"type": "hnsw"}) # 原始 float32 基线
create_index(BBQ_INDEX, {"type": "bbq_hnsw"}) # 1 比特 BBQ注意:在生产环境中,您可以使用 semantic_text 让 Elasticsearch 自动管理映射和推理端点。
我们直接调用模型 jina-embeddings-v5-text-small(无需创建 推理端点)将文本转换为向量。
import numpy as np # 导入 numpy 库
INFERENCE_ID = ".jina-embeddings-v5-text-small"
def embed(texts, batch_size=16):
out = []
for i in range(0, len(texts), batch_size):
batch = texts[i : i + batch_size]
try:
resp = es_client.inference.text_embedding(
inference_id=INFERENCE_ID, input=batch
)
except AttributeError: # 针对旧版客户端
resp = es_client.inference.inference(inference_id=INFERENCE_ID, input=batch)
out.extend(item["embedding"] for item in resp["text_embedding"])
return np.array(out, dtype=np.float32)
embed(["hello world"]).shape # 测试测试结果如下:
(1, 1024)我们从 hotchpotch/multilingual_cc_news 流式传输真实新闻文章,这是一个 CC-News 的 Parquet 镜像。我们选取了大约 1,000 篇来自五种语言的文章(总计约 3,000 篇文档),以及一小部分保留的标题作为搜索查询。使用多种语言也展示了 Jina v5 的多语言优势。
from datasets import load_dataset
LANGS = ["en", "de", "ja", "pt", "ru"]
PER_LANG_DOCS = 1000
PER_LANG_QUERIES = 20
docs, queries = [], []
for lang in LANGS:
ds = load_dataset(
"hotchpotch/multilingual_cc_news", lang, split="train", streaming=True
)
rows = [
r
for r in ds.take(PER_LANG_DOCS + PER_LANG_QUERIES)
if r.get("maintext") and r.get("title")
]
for row in rows[:PER_LANG_DOCS]:
text = (row["title"] + ". " + row["maintext"]).replace("\n", " ").strip()
docs.append({"text": text[:1000], "lang": lang})
for row in rows[PER_LANG_DOCS:]:
queries.append({"text": row["title"], "lang": lang}) # 标题作为查询
print(f"语料库: {len(docs)} 篇文档 | 查询: {len(queries)} 个")
# RES: 语料库: 3102 篇文档 | 查询: 18 个我们只对语料库进行一次 embedding,并将这些完全相同的向量输入到两个索引中。
doc_vectors = embed([d["text"] for d in docs])
query_vectors = embed([q["text"] for q in queries])def index_docs(name):
actions = (
{
"_index": name,
"_id": i,
"_source": {
"text": d["text"],
"lang": d["lang"],
"embedding": doc_vectors[i].tolist(),
},
}
for i, d in enumerate(docs)
)
helpers.bulk(es_client, actions, refresh=True)
for name in (FLOAT_INDEX, BBQ_INDEX):
index_docs(name)
es_client.indices.forcemerge(index=name, max_num_segments=1)
es_client.indices.refresh(index=name)我们 强制合并 到单个段,以使存储数据稳定且可比较。
磁盘使用 API 报告了每个索引在向量 (knn_vectors) 上花费的字节数。
def vector_disk_bytes(name):
du = es_client.indices.disk_usage(index=name, run_expensive_tasks=True)
field = du[name]["fields"]["embedding"]
knn = field.get("knn_vectors")
if isinstance(knn, dict):
return knn["size_in_bytes"]
return field["knn_vectors_in_bytes"]
float_disk = vector_disk_bytes(FLOAT_INDEX)
bbq_disk = vector_disk_bytes(BBQ_INDEX)
N = len(docs)
float_mem = N * DIMS * 4
bbq_mem = N * (DIMS // 8 + 14)
print(f"磁盘占用 -> float32: {float_disk/1e6:6.2f} MB | BBQ: {bbq_disk/1e6:6.2f} MB")
print(f"内存占用 -> float32: {float_mem/1e6:6.2f} MB | BBQ: {bbq_mem/1e6:6.2f} MB ({float_mem/bbq_mem:.0f} 倍缩小)")结果:
磁盘占用 -> float32: 12.80 MB | BBQ: 13.25 MB
内存占用 -> float32: 12.71 MB | BBQ: 0.44 MB (29 倍缩小)在磁盘上,两个索引的大小大致相同。量化索引仍然保留了原始的 float32 向量(用于重新评分和合并期间的重新量化),并在其之上添加了 1 比特向量,因此 BBQ 在磁盘上最终会略大一些。


真正的节省体现在内存方面。HNSW 扫描只需要内存中的 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 匹配的最低值。
def search_ids(index, qvec, k=10, num_candidates=10):
resp = es_client.search(
index=index,
size=k,
_source=False,
knn={
"field": "embedding",
"query_vector": qvec.tolist(),
"k": k,
"num_candidates": num_candidates,
},
)
return [h["_id"] for h in resp["hits"]["hits"]]
K = 10
# 真实情况(Ground truth):使用全精度 float32 和广泛的候选列表(近似精确)
ground_truth = [
set(search_ids(FLOAT_INDEX, qv, k=K, num_candidates=2000)) for qv in query_vectors
]
oversamples = [1, 2, 3, 5, 10]
recalls = []
for f in oversamples:
num_candidates = max(K * f, K)
hits = 0
for gt, qv in zip(ground_truth, query_vectors):
got = set(search_ids(BBQ_INDEX, qv, k=K, num_candidates=num_candidates))
hits += len(got & gt)
recalls.append(hits / (len(query_vectors) * K))
print(f"过采样 {f:>2}x -> recall@{K} = {recalls[-1]:.3f}")结果如下:

BBQ 在 1 倍过采样时,Recall@10 从 0.994 开始,保持到 3 倍过采样,然后稳定在 0.989。这意味着在所有过采样值下,它返回的 top-10 文档中,至少有 98.9% 与 float32 返回的相同。关于召回率在量化下如何因数据集而异的更多信息,请参阅 快速与准确:测量量化向量搜索的召回率。
相同的向量,两种存储格式,一次实验:
12.80 MB 对比 13.25 MB)。BBQ 保留了原始的 float 向量以供重新评分和合并使用。12.71 MB 对比 0.44 MB)。这个数字决定了您的集群能否容纳语料库。0.994。量化感知训练带来了回报。何时启用 BBQ:如果您的维度计数高于 384 维下限,如果向量是主要的内存成本,并且您可以承受为重新评分多扫描一些候选结果。对于 Jina v5 而言,该模型经过专门训练,因此在大多数语料库上的召回率损失很小。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。