首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >BBQ 量化如何将 Jina v5 embedding 缩小 29 倍,同时在 Elasticsearch 中保持召回率

BBQ 量化如何将 Jina v5 embedding 缩小 29 倍,同时在 Elasticsearch 中保持召回率

原创
作者头像
点火三周
发布2026-07-17 11:35:51
发布2026-07-17 11:35:51
550
举报
文章被收录于专栏:Elastic Stack专栏Elastic Stack专栏

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 key。

什么是量化?

embedding 是一个数字列表。默认情况下,每个数字都是一个 float32 类型,占用 4 字节。量化 则使用更少的比特来存储每个数字,以牺牲精度来换取空间。

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

JPEG 质量逐渐降低的猫咪照片,展示了量化在大小和细节之间的权衡
JPEG 质量逐渐降低的猫咪照片,展示了量化在大小和细节之间的权衡

JPEG 质量的逐渐下降(图片由 Michael Gäbler 提供,CC BY 3.0 经由 Wikimedia Commons)。

名称

每维度字节数

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 比特量化模式。向量的每个维度都以单个比特存储,每个向量额外增加几个纠正字节。然后,在查询时会应用一个重新评分步骤。这使得最终的检索质量接近于全精度搜索。

关于每个量化级别的数学原理,请参阅 Scalar quantization 101Optimized Scalar QuantizationBBQ 深度解析

BBQ 如何保持搜索准确性?

单纯的 1 比特量化本身会导致搜索质量显著下降。BBQ 通过以下三种机制保持了高检索质量:

  1. 非对称精度(Asymmetric precision):存储的向量每个维度使用 1 比特。
  2. 纠正因子(Corrective factors):每个向量存储几个浮点数,用于记录舍入误差并在评分时纠正距离。
  3. 过采样和重新评分(Oversample and rescore):BBQ 首先使用比特向量扫描候选结果,然后使用更高精度对排名前列的候选结果进行重新排序。例如,为了获取前 10 个结果,大约需要扫描 30 个候选结果。

最终,向量体积缩小了约 32 倍,同时检索质量接近于全精度。在文章的下一节中,我们将在一个真实的语料库上测量内存节省和召回率。

Jina embeddings v5 的工作原理

Jina embeddings v5 是一个多语言 embedding 模型,采用了量化感知训练,这使其与 Elasticsearch 中的 BBQ 天然契合:jina-embeddings-v5-text-small 模型产生的 1024 维向量超过了二值量化保持准确性的维度下限,并且该模型经过训练,使得 1 比特量化只会损失很小的质量。其主要特点包括:

  • 一个模型应对多任务:v5 在一个单一的基础模型之上使用了小型 Low-Rank Adaptation (LoRA) adapters,每个任务对应一个 adapter:检索文本匹配聚类分类。Elasticsearch 在索引和查询时会自动 选择正确的 adapter
  • Matryoshka 维度:v5 经过训练,可以截断向量(从 1024 维到 512 维,再到 256 维),并最大限度地减少搜索质量的下降。这是另一种缩小向量的方法,独立于量化。
  • 量化感知训练(Quantization-aware training):v5 经过训练,可以与 BBQ 协同工作,因此其 1 比特向量的准确性损失很小。

我们使用的是 jina-embeddings-v5-text-small 模型。该模型通过 Elastic Inference Service (EIS) 提供,输出 1024 维向量,支持 32k token 上下文,并支持 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 对语料库进行一次 embedding,将这些完全相同的向量索引到这两个索引中,并比较它们的磁盘使用情况、内存占用和召回率。您可以按照完整的 配套博客内容 Jupyter Notebook 进行操作。

连接到 Elasticsearch

代码语言:python
复制
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()

创建两个索引

代码语言:python
复制
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 v5 推理端点

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

代码语言:python
复制
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 # 测试

测试结果如下:

代码语言:bash
复制
(1, 1024)

加载多语言新闻数据集

我们从 hotchpotch/multilingual_cc_news 流式传输真实新闻文章,这是一个 CC-News 的 Parquet 镜像。我们选取了大约 1,000 篇来自五种语言的文章(总计约 3,000 篇文档),以及一小部分保留的标题作为搜索查询。使用多种语言也展示了 Jina v5 的多语言优势。

代码语言:python
复制
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 并批量索引

我们只对语料库进行一次 embedding,并将这些完全相同的向量输入到两个索引中。

代码语言:python
复制
doc_vectors = embed([d["text"] for d in docs])
query_vectors = embed([q["text"] for q in queries])
代码语言:python
复制
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) 上花费的字节数。

代码语言:python
复制
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} 倍缩小)")

结果:

代码语言:bash
复制
磁盘占用 -> float32:  12.80 MB | BBQ:  13.25 MB
内存占用 -> float32:  12.71 MB | BBQ:   0.44 MB  (29 倍缩小)

在磁盘上,两个索引的大小大致相同。量化索引仍然保留了原始的 float32 向量(用于重新评分和合并期间的重新量化),并在其之上添加了 1 比特向量,因此 BBQ 在磁盘上最终会略大一些。

Float32 存储接近连续的值;BBQ 量化将每个维度舍入到两个级别之一
Float32 存储接近连续的值;BBQ 量化将每个维度舍入到两个级别之一
BBQ 量化将每个向量的存储空间从 4,096 字节减少到 142 字节,减少了 29 倍
BBQ 量化将每个向量的存储空间从 4,096 字节减少到 142 字节,减少了 29 倍

真正的节省体现在内存方面。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 相符。

Elasticsearch 中的 BBQ 量化:磁盘使用量相似,但内存从 12.7 MB 降至 0.4 MB
Elasticsearch 中的 BBQ 量化:磁盘使用量相似,但内存从 12.7 MB 降至 0.4 MB

结果:召回率

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

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

我们改变过采样因子 (num_candidates / k),即 BBQ 使用 1 比特向量扫描候选结果的数量,然后在 重新排序 阶段使用原始浮点数对排名靠前的候选结果进行重新排序,以找到与 float32 匹配的最低值。

代码语言:python
复制
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 量化在过采样因子 1x 到 10x 之间,Recall@10 保持在 0.989 附近,与 float32 相比
BBQ 量化在过采样因子 1x 到 10x 之间,Recall@10 保持在 0.989 附近,与 float32 相比

BBQ 在 1 倍过采样时,Recall@10 从 0.994 开始,保持到 3 倍过采样,然后稳定在 0.989。这意味着在所有过采样值下,它返回的 top-10 文档中,至少有 98.9% 与 float32 返回的相同。关于召回率在量化下如何因数据集而异的更多信息,请参阅 快速与准确:测量量化向量搜索的召回率

BBQ 量化结果总结

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

  • 磁盘占用:大致相同(12.80 MB 对比 13.25 MB)。BBQ 保留了原始的 float 向量以供重新评分和合并使用。
  • 内存占用:缩小 29 倍(12.71 MB 对比 0.44 MB)。这个数字决定了您的集群能否容纳语料库。
  • Recall@10:在 1 倍过采样时达到 0.994。量化感知训练带来了回报。

何时启用 BBQ:如果您的维度计数高于 384 维下限,如果向量是主要的内存成本,并且您可以承受为重新评分多扫描一些候选结果。对于 Jina v5 而言,该模型经过专门训练,因此在大多数语料库上的召回率损失很小。

BBQ 和向量量化延伸阅读


原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 前提条件
  • 什么是量化?
  • 什么是 BBQ?
    • BBQ 如何保持搜索准确性?
  • Jina embeddings v5 的工作原理
  • 设置 BBQ 与 float32 的对比实验
    • 连接到 Elasticsearch
    • 创建两个索引
    • 指向 Jina v5 推理端点
    • 加载多语言新闻数据集
    • 生成 embedding 并批量索引
  • 结果:磁盘与内存
  • 结果:召回率
  • BBQ 量化结果总结
  • BBQ 和向量量化延伸阅读
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档