Browse Source

feat(更新HF_safetensors到GGUF转换文档): 扩展HF_safetensors->GGUF.md文档,新增对ViT切割机制及投影器映射过程的详细解析,澄清量化对OCR精度的影响,提升用户对模型转换及使用的理解与指导。

zhch158_admin 1 month ago
parent
commit
58a7281a79
1 changed files with 153 additions and 1 deletions
  1. 153 1
      docs/ocr_tools/universal_doc_parser/HF_safetensors->GGUF.md

+ 153 - 1
docs/ocr_tools/universal_doc_parser/HF_safetensors->GGUF.md

@@ -64,6 +64,76 @@ MinerU2.5-Pro 是个视觉-语言文档解析模型(PDF→Markdown),整体
 所以三者是**串行协作、缺一不可**的:投影器和编码器决定 LLM「看到了什么」,LLM 决定「怎么把看到的说出来」。这也是 MinerU2.5-Pro、PaddleOCR-VL 转换时必须同时导出主体模型和 `mmproj` 两个 GGUF 的原因——少了 mmproj,模型就「瞎」了。
 
 ---
+### 进一步问题
+1. ViT把图片切成小块(patch),是否会造成文字不论从横向还是纵向的不完整,向量维度和语义空间是否会对齐?
+2. 如果文字不完整,投影器又是如何将视觉patch编码映射到LLM的embedding空间?
+
+两个问题问到了 VLM 的核心机制。简短回答:**单个 patch 的"切割不完整"几乎不影响结果,因为真正的语义理解不发生在单个 patch 层面,而是发生在 ViT 的全局自注意力之后。** 下面拆开讲。
+
+## 1. 切 patch 确实会切断文字,但这不是问题
+
+你的直觉完全正确:ViT 把图片按固定网格(比如 14×14 或 16×16 像素一块)硬切,**一个汉字、一个字母确实经常会被横向或纵向切到两三个 patch 里**,笔画被拆开。
+
+但 ViT 不是「一个 patch 单独识别一个字」的结构。关键在于:
+
+### 自注意力(Self-Attention)让每个 patch 都能"看到"所有其他 patch
+
+ViT 的每一层 Transformer 里,**任意一个 patch 的向量都会和图中所有其他 patch 做注意力交互**。所以经过若干层之后:
+
+- 含「某字左半边」的 patch,会从相邻含「右半边」的 patch 那里聚合信息;
+- 最终这个 patch 的输出向量,**已经不只代表它那一小块像素,而是编码了它在整张图里的上下文**。
+
+加上 **位置编码(position embedding)** 记录了每个 patch 的二维坐标,模型知道「这块在那块的右边/下边」,于是被切断的笔画在特征空间里被重新「拼回」完整字符的语义。
+
+### 类比
+就像你把一张写满字的纸撕成小方块,单看一块碎片认不出字;但如果你能同时看到所有碎片、并且知道每块原来的位置,你的大脑就能拼出完整内容。自注意力 + 位置编码就是干这件事的。
+
+### 工程上还有额外保障
+- **小 patch + 高分辨率**:一个字往往横跨多个 patch,信息冗余度高,丢一点边角无所谓。
+- **Qwen2-VL 的动态分辨率**:它不强制把图缩成固定尺寸,而是按原图比例切,避免文字被压扁变形;并且会把相邻 2×2 的 patch 合并,兼顾细节和效率。
+- **端到端训练**:整个模型是拿海量「图→文字」样本训出来的,它在训练中早就"学会"了如何处理被切碎的字符——这是数据驱动的能力,不是靠人工对齐 patch 边界。
+
+## 2. 投影器如何映射"不完整"的 patch —— 其实它拿到的已经不是碎片了
+
+这是理解的关键点:**投影器(projector / mmproj)不是直接对接原始切碎的 patch,而是对接 ViT 全部自注意力层处理之后的输出。**
+
+数据流的精确顺序:
+
+```
+原始 patch(碎片,局部像素)
+   │
+   ▼
+[ViT 的 N 层自注意力]  ← 这里完成了跨 patch 的信息融合
+   │
+   ▼
+富含全局上下文的 patch 向量(每个向量已"懂"自己代表什么字/结构)
+   │
+   ▼
+[投影器 Projector]  ← 它处理的是上面这种"已理解"的向量
+   │
+   ▼
+LLM embedding 空间
+```
+
+所以回答你的疑问:**投影器面对的从来不是"半个字",而是已经被 ViT 注意力机制重组、带有完整语义的特征向量。** 文字完不完整的问题,在到达投影器之前就已经被 ViT 内部解决了。
+
+### 投影器具体做两件事
+
+1. **维度对齐**:ViT 隐藏维度(比如 1152、1280)和 LLM 隐藏维度(比如 1536、3584)通常不一样。投影器(一般是 1~2 层 MLP)把视觉向量线性变换到 LLM 要求的维度。
+
+2. **语义空间对齐**:ViT 和 LLM 是两个独立训练出来的模型,各自的向量空间含义不通。投影器在训练中学到一个映射,让视觉特征"翻译"成 LLM 词向量空间里有意义的点——使 LLM 把这些图像 token 当成"能读懂的词"来处理。
+
+### 最后 LLM 还会再融合一次
+进入 LLM 后,这些图像 token 会和文本 token 一起,在 LLM 自己的注意力层里**再次跨 token 交互**。也就是说还有第二轮"全局拼合"。所以即使个别视觉 token 信息有损,LLM 也能借助语言先验和上下文(比如它知道"这是一段中文句子,根据前后文这里应该是某个字")进一步纠错补全。
+
+## 一句话总结
+
+- 切碎不要紧:**ViT 的自注意力 + 位置编码**在投影器之前就把被切断的字符在特征层面重组完整了。
+- 投影器拿到的是"已理解的全局特征"而非"像素碎片",它只负责**维度 + 语义空间的对齐翻译**。
+- LLM 端的注意力 + 语言先验再做一轮纠错,最终输出连贯的文字。
+
+整个能力是**端到端训练**赋予的——模型从数据里自己学会了「碎片化输入 → 完整文字」的鲁棒映射,而不是靠在 patch 边界上做精确切字。
+---
 
 ## 关键事实
 
@@ -130,7 +200,7 @@ cp ~/models/MinerU2.5-Pro-2604-1.2B/chat_template.jinja ~/models/MinerU2.5-Pro-2
 
 ```
 
-> 模型总共才 ~1.5GB,48G 内存下**不建议量化**主模型(Q4 会掉 OCR 精度),直接 f16 即可;mmproj 用 f16 或 f32 质量最佳
+> 模型总共才 ~1.5GB,48G 内存下直接 f16 即可;想省内存/略提速可对主模型做 **Q8_0(几乎无损,详见后文「f16 vs Q8_0 量化」)**,但**不要用 Q4**(会掉 OCR 精度);**mmproj 始终保持 f16/f32**,不要量化
 
 转完后,把你 `paddle_local_daemon.sh` 里这几行指向新文件就行:
 
@@ -230,6 +300,88 @@ python convert_hf_to_gguf.py ~/models/MinerU2.5-Pro-2604-1.2B \
 
 ---
 
+## f16 vs Q8_0 量化
+
+结论先行:**对这类 OCR/VLM 小模型,Q8_0 是「几乎无损」的,可以放心用;真正要避开的是 Q4/Q5。**
+
+### 精度
+
+| 量化 | 位宽 | 困惑度损失 | OCR(数字/金额/单号)影响 |
+|---|---|---|---|
+| f16 | 16-bit | 基准 | 基准 |
+| **Q8_0** | 8-bit(每 32 权重一个 scale) | 通常 **< 0.1%**,业界视为"几乎无损" | 实测无可感知差异 |
+| Q5_K_M | ~5.5-bit | 较小 | 偶有个位错字,需评测 |
+| Q4_K_M | ~4.5-bit | 明显 | **数字/相似字易出错,不建议 OCR 用** |
+
+OCR 对权重精度比闲聊更敏感(要精确分辨 `8/0`、`3/5`、相似汉字),所以低位量化的风险高于普通文本生成;而 Q8_0 的误差量级远低于 OCR 容错阈值,安全。
+
+### 效率(Apple Silicon / Metal)
+
+- **体积**:Q8_0 约为 f16 的 ~53%。1.2B:~2.4GB → ~1.3GB;0.9B:~1.8GB → ~1.0GB。
+- **速度**:解码主要受**内存带宽**制约,权重读取量减半 → token 生成速度通常**持平或略快**;但 Q8_0 有反量化开销,算力充足时提升有限。对当前 ~168 t/s 的水平,预期持平到小幅提升。
+- **收益定位**:模型本就小,Q8_0 的主要价值是**省一半内存 + 不掉精度**,而非大幅提速。若内存不紧张,f16 也完全够用。
+
+### 为什么不直接 `convert_hf_to_gguf.py --outtype q8_0` 一步到位?
+
+**可以,而且对「只要 Q8_0」的场景完全够用、质量与两步法基本无差异。** `--outtype` 支持的类型是:
+
+```
+f32 / f16 / bf16 / q8_0 / tq1_0 / tq2_0 / auto
+```
+
+`q8_0` 在列,所以一步直出没问题:
+
+```bash
+python convert_hf_to_gguf.py ~/models/MinerU2.5-Pro-2604-1.2B \
+    --outfile ~/models/MinerU2.5-Pro-2604-1.2B-GGUF \
+    --outtype q8_0
+# mmproj 仍单独转、保持 f16(见前面步骤)
+```
+
+默认推荐**先 f16 母本再 `llama-quantize`**的真实理由(不是因为 `--outtype q8_0` 不行):
+
+1. **K-quants 只能走 `llama-quantize`**:`--outtype` **不支持** `Q4_K_M`/`Q5_K_M`/`Q6_K` 等。一旦想试这些更优的低位量化,必须 f16 → `llama-quantize`。
+2. **留一份 f16 高精度母本**:之后想要 Q8_0、Q5_K_M、Q6_K 等任意量化都基于它**秒级生成**,无需每次从 safetensors 重转(重转慢,还要重跑 tokenizer 的 OTSL `special` 修复那步)。
+3. **imatrix 校准量化**也只能经 `llama-quantize`。
+
+一句话:**只用 Q8_0、不折腾其他 → `--outtype q8_0` 更省事;可能还要试别的量化 / 想留母本 → 走两步法。**
+
+### 量化脚本(基于已转好的 f16 主模型,用 `llama-quantize`)
+
+> 量化是对**已有 f16 GGUF**的再压缩,**无需**从 safetensors 重转,也**不动 mmproj**。
+> 之前对 MinerU2.5 做的 OTSL token `special` 修复已固化在 f16 里,量化会原样保留 tokenizer 元数据,**无需重做**。
+
+```bash
+LLAMA_BIN="$HOME/workspace/repository.git/llama.cpp/build/bin"
+
+# MinerU2.5:只量化语言主体,mmproj 保持 f16 不动
+"$LLAMA_BIN/llama-quantize" \
+    ~/models/MinerU2.5-Pro-2604-1.2B-GGUF/MinerU2.5-Pro-2604-1.2B-F16.gguf \
+    ~/models/MinerU2.5-Pro-2604-1.2B-GGUF/MinerU2.5-Pro-2604-1.2B-Q8_0.gguf \
+    Q8_0
+
+# PaddleOCR-VL-1.6:同理
+"$LLAMA_BIN/llama-quantize" \
+    ~/models/PaddleOCR-VL-1.6-GGUF/PaddleOCR-VL-1.6-F16.gguf \
+    ~/models/PaddleOCR-VL-1.6-GGUF/PaddleOCR-VL-1.6-Q8_0.gguf \
+    Q8_0
+```
+
+> 文件名按实际 f16 文件调整(转换产物一般是 `*-F16.gguf`)。`llama-quantize` 最后一个参数即量化类型,可选 `Q8_0`、`Q5_K_M`、`Q4_K_M` 等。
+
+启用:把对应 daemon 的 `MODEL_PATH` 指向 `*-Q8_0.gguf`(`MMPROJ_PATH` 不变),重启即可。
+
+### 验证(量化后务必做)
+
+```bash
+ocr_tools/daemons/mineru_local_daemon.sh restart
+ocr_tools/daemons/curl_local_mineru.sh
+```
+
+确认两点:①`content` 里 OTSL 结构 token(`<fcel>`/`<nl>`)仍在;②抽查几行金额/单号与 f16 输出是否一致。一致即可放心用 Q8_0。
+
+---
+
 ## 不推荐的路径
 
 - **transformers + MPS**:Mac 上 MPS 跑视觉模型慢,且官方明确说 transformers 路径只支持 element-level(单元素识别),**不支持整页解析**,会破坏你现在的流水线。