两周前,我们发布了
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
原生支持现代的纯解码器的多模态向量模型,为我们当前及未来的重排器版本提供坚实基础。