回归C++: 在GGUF上构建高效的向量模型

发布于:2025-08-16 ⋅ 阅读:(16) ⋅ 点赞:(0)

两周前,我们发布了 jina-embeddings-v4 的 GGUF 格式及其多种动态量化版本。jina-embeddings-v4 原模型有 37.5 亿参数,在我们的 GCP G2 GPU 实例上直接运行时效率不高。因此,我们希望通过更小、更快的 GGUF 格式来加速推理。

在转换和运行这些 GGUF 模型的过程中,我们踩坑很多也积累了一些小技巧。而目前 llama.cpp 开发者社区主要聚焦于大型语言模型,因此我们想从一个向量模型供应商的角度,简单来聊聊我们的经验。

很多人可能没意识到,现在的向量模型和大型语言模型(LLM)在架构上基本一致。例如,jina-embeddings-v4 基于 Qwen2.5-VL-3B-instruct,而 jina-reranker-m0 是基于 Qwen2-VL-2B。唯一的本质区别在于输出:LLM 的输出是生成式的,而向量模型和重排器的输出是判别式的。

这种一致性有好有坏:好的是,我们可以直接利用 llama.cpp 的高效实现(如 ubatch_size和kv cache)来运行模型;现实是:llama.cpp 中现有的向量功能实现,大多是围绕着旧式的 BERT/RoBERTa 的纯编码器 encoder-only 架构开发的,尚未适配现代纯解码器的 decoder-only 向量模型。

本文将分享我们如何把纯解码器的向量模型适配到 GGUF 格式以及在 llama.cpp 工具链(如 llama-embedding 和 llama-serving)的优化实操经验。

基础版与量化版 GGUF

jina-embeddings-v4 基于 Qwen2.5-VL-3B-instruct 构建,并集成了三个 LoRA 适配器,分别针对:

  • retrieval:文档检索任务

  • text-matching:文本匹配任务

  • code:代码检索任务

模型本身也为视觉文档检索和多向量输出进行了深度训练。我们的思路是,复用 llama.cpp 中已有的 Qwen2.5-VL-3B 计算图,再通过 llama-embedding 执行推理。

但我们一上来就发现,llama.cpp 的 mmproj(视觉 Transformer)的实现存在 bug。给定相同的图像输入,它生成的向量结果与 Qwen2.5-VL-3B 的 Torch 实现不一致。我们正在自己的分支(fork) 中修复这个问题。但在此期间,我们决定在目前发布的 GGUF 版本中移除视觉模块。

我们向上游提交的bug报告:https://github.com/ggml-org/llama.cpp/discussions/14851

多向量输出功能同样也没有原生支持,但这个问题大。多向量输出来自最后一个 Transformer 模块中的一个 MLP。因此,最差情况下,我们可以先导出这个 MLP,等 llama.cpp 输出 token 级向量后,再手动应用它。jina-reranker-m0-GGUF 就是采用这种方式实现的。这个方法虽然计算效率不高,但好在MLP比较小,且不用修改和重新编译 llama.cpp 就能工作。

所以,为了完全兼容 llama.cpp 现有的 Qwen2.5-VL-3B 计算图,我们剥离了视觉 Transformer 和多向量投射器,然后将所有 LoRA 适配器合并回基础语言模型。

最终,我们得到了三个针对特定任务的 v4 模型,每个模型的参数量从 37.5 亿减少到了 30.9 亿。

我们针对不同任务,创建了三个 GGUF 仓库:

HuggingFace 仓库

任务

jinaai/jina-embeddings-v4-text-retrieval-GGUF

文本检索

jinaai/jina-embeddings-v4-text-code-GGUF

代码检索

jinaai/jina-embeddings-v4-text-matching-GGUF

句子相似度

接着,我们使用 Unsloth 推荐的 calibration_data_v5_rc.txt 校准文件,为上述三个基础 GGUF 模型分别生成了 imatrix 重要性矩阵文件。然后,我们结合 imatrix 文件,调用 llama-quantize 工具将 float16 模型执行量化。

具体命令如下:

# 构建 imatrix 文件
llama-imatrix -m jina-embeddings-v4-text-retrieval-F16.gguf -f calibration_data_v5_rc.txt -ngl 99 --no-ppl -o imatrix-retrieval-512.dat

# 执行量化
./quantize.sh jina-embeddings-v4-text-retrieval-F16.gguf retrieval-i3 imatrix-retrieval-512.dat jinaai/jina-embeddings-v4-text-retrieval-GGUF

这里的 quantize.sh 脚本负责批量处理不同的量化类型:

#!/bin/bash

F16_MODEL_FILE="$1"
OUTPUT_DIR="$2"
IMATRIX="$3"
HF_REPO="$4"

FILENAME="$(basename "$F16_MODEL_FILE")"
BASE_NAME="${FILENAME%-F16.gguf}"
BASE_NAME="${BASE_NAME%.gguf}"

mkdir -p "$OUTPUT_DIR"

# 定义量化类型数组
QUANT_TYPES=("IQ1_S""IQ1_M""IQ2_XXS""IQ2_M""Q2_K""IQ4_NL""IQ4_XS""IQ3_XXS""IQ3_S""IQ3_M""IQ3_XS""Q3_K_M""Q4_K_M""Q5_K_S""Q5_K_M""Q6_K""Q8_0")

# 循环执行量化
for quant_type in"${QUANT_TYPES[@]}"; do
    llama-quantize --imatrix "${IMATRIX}""$F16_MODEL_FILE""${OUTPUT_DIR}/${BASE_NAME}-${quant_type}.gguf"$quant_type 8
done

最终,我们将所有量化模型上传至 HuggingFace。

量化类型

BPW (每权重比特数)

文件大小 (GB)

IQ1_S

2.04

0.73

IQ1_M

2.19

0.79

IQ2_XXS

2.44

0.88

IQ2_M

2.94

1.06

Q2_K

3.29

1.18

IQ3_XXS

3.31

1.19

IQ3_XS

3.59

1.29

IQ3_S

3.76

1.35

IQ3_M

3.84

1.38

Q3_K_M

4.11

1.48

IQ4_NL

4.72

1.69

IQ4_XS

4.49

1.61

Q4_K_M

4.99

1.79

Q5_K_S

5.61

2.02

Q5_K_M

5.75

2.07

Q6_K

6.56

2.36

Q8_0

8.50

3.05

F16

16.00

5.75

v3 (Transformers)

16.00

1.10

v4 (Transformers)

16.00

7.40

用法与注意事项

现在,我们可以用 llama-server 和 llama-embedding 来部署 GGUF 向量模型。

Transformer 库允许我们灵活编写输入预处理代码。但在 llama.cpp 中,我们必须手动完成这一步,除非你打算重新编译 llama-server。为了确保 GGUF 模型的输出结果与原始 jina-embeddings-v4 模型完全一致,你 必须非常小心地 为输入内容手动添加前缀。

参考以下表格:

任务

prompt_name

 (Transformer 实现)

模型的实际输入

retrieval query

 (默认)

Query: {原始文本}
retrieval passage Passage: {原始文本}
text-matching query

 (默认)

Query: {原始文本}
text-matching passage Query: {原始文本}

 ⚠️

code query

 (默认)

Query: {原始文本}
code passage Passage: {原始文本}

有些用户可能会对 ⚠️ 标记处感到奇怪:在 text-matching 任务中,即使指定 prompt_name='passage',输入前缀依然会被强制改为 "Query: "。这个设计其实很合理。因为 text-matching 是一个句子相似度任务,输入内容没有主次之分,两者是对称的。

通过 llama-server 部署

安装 llama.cpp 后,运行 llama-server 命令,即可将向量模型部署为一个兼容 OpenAI API 的 HTTP 服务。例如,要启动 text-matching 的 F16 模型,可以执行:

llama-server -hf jinaai/jina-embeddings-v4-text-matching-GGUF:F16 --embedding --pooling mean -ub 8192

必须添加 --pooling mean 参数,因为 v4 模型采用均值池化生成向量。

然后,通过 curl 发送请求:

curl -X POST "http://127.0.0.1:8080/v1/embeddings" \
  -H "Content-Type: application/json" \
  -d '{
    "input": [
      "Query: A beautiful sunset over the beach",
      "Query: Un beau coucher de soleil sur la plage",
      "Query: 海滩上美丽的日落",
      "Query: 浜辺に沈む美しい夕日"
    ]
  }'

如果使用 retrieval 和 code 模型,则需要根据输入类型,手动添加 Query: 或 Passage: 前缀:

curl -X POST "http://127.0.0.1:8080/v1/embeddings" \
  -H "Content-Type: application/json" \
  -d '{
    "input": [
      "Query: A beautiful sunset over the beach",
      "Query: Un beau coucher de soleil sur la plage",
      "Passage: 海滩上美丽的日落",
      "Passage: 浜辺に沈む美しい夕日"
    ]
  }'

通过 llama-embedding 执行

要快速验证,你也可以使用预编译的 llama-embedding 工具进行单次向量编码。但我们 不推荐 用它来批量处理,因为它存在性能问题,我们会在下文详细讨论。

llama-embedding -hf jinaai/jina-embeddings-v4-text-matching-GGUF:F16 --pooling mean -p "Query: jina is awesome" --embd-output-format json  2>/dev/null

注意事项小结

在开始之前,需要了解使用 GGUF 模型时要注意的几个要点:

  • 必须手动添加前缀:你必须在输入文本前手动添加 Query: 或 Passage:

  • 暂不支持图像:当前版本无法处理图像输入。因为 llama.cpp 在实现 Qwen2.5-vl-3b 的视觉模块 (mmproj) 时存在错误,我们在 GGUF 模型中移除了该功能。目前,我们正与上游社区合作解决此问题。

  • 暂不支持多向量输出llama.cpp 的 Qwen2.5-vl-3b 计算图并未实现多向量输出。最简单的绕过方法是,设置 --pooling none 从 llama.cpp 获取 token 级向量,然后单独导出并运行 MLP。这样做无需重新编译代码。

  • Matryoshka 嵌套表示特性:v4 模型使用了 Matryoshka 嵌套表示学习进行训练,GGUF 格式也保留了这一特性。因此,当你获得一个 NxD 形状的向量时,可以通过 embeddings[:, :truncate_dim] 直接截断,得到更小的向量。但我们只针对特定维度 [128, 256, 512, 1024, 2048] 进行了训练。如果你截断到 131 维,其质量不会介于 128 维和 256 维之间,而是会比两者差很多,因为 131 维没有经过训练。

  • 迟分(Late Chunking)的局限:你依然可以将“迟分”作为一个后处理步骤。但 v4 是一个因果模型,迟分不再是双向的:前面的文本块向量,将无法包含后续文本块的上下文信息。而在 v3 中,因为我们使用了双向注意力,每个文本块都包含了全局上下文。关于因果性是否让迟分技术过时,我们团队内部对此也有争议。一方认为,阅读本身就是从前到后的因果过程,上下文自然来自前方。另一方则认为,单向注意力机制限制了信息的充分流动。因此,“迟分”技术在 v4 模型中的实际效果,仍需我们进一步研究和验证。

使用 llama-embedding 实现高效向量

llama-embedding 是 llama.cpp 的一个 C++ 封装库,它通过标准输入输出(stdin, stdout)处理文本,接口设计得非常简洁。我们选择重点优化llama-embedding,而非 llama-server,因为后者涉及的网络排队、负载均衡等问题超出了我们当前的范围。我们关注的核心问题更纯粹:一块 24GB 显存的 L4 GPU,其性能极限在哪里?处理长文档时,它的显存占用峰值会达到多少?

为什么选择 L4?因为 GCP 基于 L4 提供了便捷的 Cloud Run 服务,而且 L4 是目前最普及、最经济的无服务器推理 GPU。虽然 GCP 也能提供 A100/H100,但我们CEO的思维很直接:如果一个 3B 参数的模型就动辄喊着用 A100/H100 来部署,那不是炫,是我们还得练(skill issue)。

我们先介绍一下几个影响性能的关键参数。在 llama.cpp 中:

  • 逻辑批次大小 (-b) :代表单次评估调用中提交给模型的最大 token 数。

  • 物理批次大小 (-ub) :代表硬件一次前向传播中同时处理的实际 token 数,受显存限制。

  • 上下文窗口 (-c) :是模型一次能“看到”的 token 总数上限。v4 模型是 32,000。

下图描绘了三者之间的关系。

关系图
关系图

我们做出的改进

在我们的代码分支 https://github.com/hanxiao/llama.cpp  中,我们做了几项优化来提升 llama-embedding 的效率:

  • 简化批次处理:我们淘汰了 -b 参数,让数值自动与上下文长度 -c 保持一致。这样一来,用户不用手动设置它,因为我们总是利用模型的全部上下文长度进行逻辑批处理。

  • 灵活控制显存:原版 llama.cpp 实现里,强制物理批次大小 -ub 等于逻辑批次大小 -b,但我们解除了这一绑定,允许用户独立设置 -ub。这让用户在编码长文本时能精确控制显存峰值。例如,你可以用一个很小的 512-token 物理批次来处理 32K 的长上下文。请注意,此项修复仅适用于像 jina-embeddings-v4 这样的因果向量模型。

  • 修正均值池化:我们修复了当物理批次 (ub) 小于逻辑批次 (b) 时,程序无法准确计算均值池化的 bug。

这些改动极大地简化了长文本、仅解码器向量模型的使用,同时能有效管理显存。用户现在只需配置两个参数:

  • -c:最大上下文长度。

  • -ub:物理批次大小。

以下是在 L4 GPU 上运行我们代码分支的完整命令:

# 编译
git clone https://github.com/hanxiao/llama.cpp.git
cd llama.cpp
cmake -B build -DGGML_CUDA=ON
cmake --build build --config Release -j 8

# 运行
INPUT_PREFIX="Query: "# 或 "Passage: "

cat big_input.txt | sed "s/^/${INPUT_PREFIX}/" | \
./llama.cpp/build/bin/llama-embedding -f /dev/stdin \
    -hf "jinaai/jina-embeddings-v4-text-retrieval-GGUF:FP16" \
    --pooling mean \
    --no-escape \
    --embd-output-format array \
    --ubatch-size 512 \
    --ctx-size 8192 \
    --flash-attn \
    -ngl 99 \
    > "embeddings.txt" 2> "error.log"

big_input.txt 文件中的每一行都是一个待向量的句子。--no-escape 确保句子中的换行符不被误判为分隔符。--flash-attn 和 -ngl 99 则用于在 L4 GPU 上获得最佳性能。

基准测试

我们做基准测试,旨在回答以下几个问题:

  • 对比原始的 v4 Float16 模型,我们的量化模型表现如何?性能在哪个节点会下降到不如直接使用 v3 模型?

  • 在 L4 GPU 上,每种量化版本的运行速度有多快?峰值显存占用是多少?

  • 物理批次大小 -ub 和上下文长度 -c 如何影响速度与峰值显存?

我们使用了以下数据集进行基准测试:

任务

文档数

查询数

相关对

平均文档长度

最大文档长度

平均查询长度

NanoHotpotQA

5,090

50

100

57.3

345

14.9

NanoSciFact

2,919

50

56

205.8

1524

13.5

NanoArguAna

3,635

50

50

164.5

1058

193.0

NanoNFCorpus

2,953

50

2,518

223.3

1460

3.3

NanoFiQA2018

4,598

50

123

159.1

1882

10.2

我们使用自构建的 llama-embedding 工具执行所有测试。

量化质量

测试表明,表现最好的量化版本是 IQ3_M (3.84 BPW)。低于 2 bits 的量化版本,其性能甚至不如 v3,因此基本没有使用价值。

不同量化等级在各数据集上的性能分布,得分越高越好。

量化类型

NanoHotpotQA

NanoFiQA2018

NanoArguAna

NanoNFCorpus

NanoSciFact

IQ1_S

0.6369

0.3178

0.3798

0.2933

0.5934

IQ1_M

0.6316

0.3313

0.5167

0.3256

0.6114

IQ2_XXS

0.7236

0.4582

0.4584

0.4067

0.7392

IQ2_M

0.7427

0.5869

0.5090

0.4468

0.7880

Q2_K

0.7683

0.5744

0.5168

0.4183

0.7546

IQ3_XXS

0.7780

0.5991

0.4811

0.4267

0.7610

IQ3_XS

0.7727

0.5615

0.5195

0.4439

0.7726

IQ3_S

0.8002

0.5505

0.4886

0.4381

0.7690

IQ3_M

0.8106

0.5387

0.5091

0.4462

0.7760

Q3_K_M

0.7567

0.5267

0.4486

0.4092

0.7775

IQ4_NL

0.7930

0.5598

0.4911

0.4285

0.7794

IQ4_XS

0.7979

0.5627

0.4947

0.4258

0.7789

Q4_K_M

0.8029

0.5569

0.4883

0.4226

0.7877

Q5_K_S

0.7969

0.5581

0.4721

0.4288

0.7842

Q5_K_M

0.7927

0.5601

0.4745

0.4247

0.7873

Q6_K

0.7951

0.5636

0.4822

0.4337

0.7846

Q8_0

0.7938

0.5687

0.4784

0.4335

0.7851

F16

0.7940

0.5610

0.4931

0.4343

0.7963

v3 (Transformers)

0.7393

0.5144

0.4600

0.4068

0.7820

v4 (Transformers)

0.7977

0.5571

0.4844

0.4351

0.7963

速度与显存

我们固定使用 NanoHotpotQA 数据集,测试所有量化版本的速度和显存。我们发现,FP16 精度的 GGUF 版本(2023 tok/s)甚至比原生版本(1865 tok/s)略快。大多数量化版本的速度集中在 2000-2100 tok/s。启用 Flash Attention 后,所有量化版本的速度普遍提升约 77%,达到 3000 tok/s 以上。然而,即便速度最快的 Q8_0 版本(约 3700 tok/s),仍远不及原生 v3 模型(16000 tok/s)。不过,量化版本显著节省了显存,IQ3 等级的版本其显存占用已接近 v3 FP16 模型。

不同量化等级在速度和显存占用上的表现。理想的模型位于图表的右上角,即低显存、高速度。

量化类型

BPW

文件大小 (GB)

峰值显存 (GB)

Token/s (启用 FA)

Token/s (未启用 FA)

IQ1_S

2.04

0.73

4.04

3625

2050

IQ1_M

2.19

0.79

4.09

3349

1997

IQ2_XXS

2.44

0.88

4.19

3701

2071

IQ2_M

2.94

1.06

4.37

3407

1989

Q2_K

3.29

1.18

4.49

3173

1905

IQ3_XXS

3.31

1.19

4.50

3668

2067

IQ3_XS

3.59

1.29

4.60

3604

2053

IQ3_S

3.76

1.35

4.66

3599

2049

IQ3_M

3.84

1.38

4.69

3603

2053

Q3_K_M

4.11

1.48

4.78

3450

2008

IQ4_NL

4.72

1.69

5.00

3571

2039

IQ4_XS

4.49

1.61

4.92

3585

2046

Q4_K_M

4.99

1.79

5.10

3558

2045

Q5_K_S

5.61

2.02

5.32

3567

2044

Q5_K_M

5.75

2.07

5.38

3528

2034

Q6_K

6.56

2.36

5.66

3334

1981

Q8_0

8.50

3.05

6.36

3767

2101

F16

16.00

5.75

9.70

3399

2023

v3 (Transformers)

16.00

1.10

2.82

16505

v4 (Transformers)

16.00

7.40

14.45

1865

最佳物理批次与上下文大小

现在,我们固定使用 IQ3_S 量化版本,来研究物理批次大小 (-ub) 和上下文大小 (-c) 如何影响速度与显存。在 L4 GPU 上的测试结果显示,当 -ub=512 且 -c=2048 时,配置达到最优,速度为 4,143 tok/s,显存占用 2,025MB。

由此得出的结论很直观:当你知道输入文档的最大长度时,应设置一个刚好能覆盖它的最小上下文。至于物理批次大小,512 在 L4 GPU 上似乎是最佳选择。

性能热力图展示了不同 -c 和 -ub 组合下的速度(左)和显存占用(右)。颜色越亮代表速度越快或显存占用越高。
每秒处理 Token 数性能

物理批次大小 (ubatch_size)

上下文=64

上下文=128

上下文=256

上下文=512

64

2233

2093

2128

2125

128

不适用

2866

2821

2877

256

不适用

不适用

3287

3349

512

不适用

不适用

不适用

3469

物理批次大小 (ubatch_size)

上下文=2048

上下文=4096

上下文=8192

上下文=16384

256

3971

3630

3593

2766

512

4143

3797

3758

2852

1024

4059

3742

3707

2822

2048

3957

3631

3603

2762

4096

不适用

3450

3410

2625

峰值显存占用 (MB)

物理批次大小 (ubatch_size)

上下文=64

上下文=128

上下文=256

上下文=512

64

1691

1689

1689

1697

128

不适用

1729

1727

1737

256

不适用

不适用

1803

1811

512

不适用

不适用

不适用

1963

物理批次大小 (ubatch_size)

上下文=2048

上下文=4096

上下文=8192

上下文=16384

256

1885

1947

2099

2409

512

2025

2101

2257

2577

1024

2329

2407

2571

2917

2048

2933

3025

3203

3597

4096

不适用

4285

4497

4985

结论

对于希望在低成本 GPU 上高效运行 v4 量化模型 GGUF 的用户,我们推荐选择 IQ3_S 或 IQ3_M 版本,并配合我们定制的 llama-embedding 工具。在常规数据集(句子长度小于 2048 token)上,这套方案能达到 4000 tok/s 的处理速度。若要处理更长的文档,只需增大上下文 -c,并适当控制物理批次大小 -ub,就能有效降低显存占用。借助我们的定制工具,你可以将 -ub 设为 1024 这样的小值,仅用 3GB 显存就能编码超长文档(>32K token)——这在原版实现或原生 Transformers 中是无法做到的。

对速度优化的追求永无止境。我们总希望找到更快、更精简、吞吐量更高的实现方法。4000 tok/s 远非我们的上限,未来还有大量工作要做。除了修复 llama.cpp 中 qwen2.5-vl-3b 的视觉模块实现,我们还在探索更深层次的 llama.graph 和 KV 缓存优化,改进 llama-serving 的批处理逻辑,并为向量 API 增加流式处理选项。

我们的最终目标,是让 llama.cpp 原生支持现代的纯解码器的多模态向量模型,为我们当前及未来的重排器版本提供坚实基础。