mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-21 17:38:10 +00:00
feat(memory): 千问三段式 ONNX 嵌入补齐——可复现导出脚本 + Go 侧首次完整验证
此前三段式拆分后 ONNX 路径从未从 Go 侧跑通:embedder_onnx_test.go 仍引用 分段前的 API(e.renderInput、TextTower.onnx、旧目录),go vet -tags onnxruntime 直接编译失败。导出脚本只在 /tmp 且硬编码本机路径、从第三个目录拷贝固定形状的 Vision.onnx,完全不可复现。音频会被视觉塔编码,静默往统一空间灌入错误坐标。 本提交补齐这些缺口: 一、可复现导出脚本(scripts/export_qwen3vl_embedding_onnx.py) - 自动拉取模型(HuggingFace 优先,失败回落 ModelScope,支持 HF_ENDPOINT 镜像); - 导出 TokenEmbedding + Transformer + Vision 三段图,图文共用同一 token embedding、28 层 Transformer、last-token 池化与 fingerprint; - 双重自检(不可省):分段 PyTorch vs 完整模型 + 导出后的 ONNX vs 完整模型, cos < 0.999999 即非零退出——「能加载」不等于「算得对」; - 默认把 L2 归一化后的冻结参考向量写入产物目录(qwen_reference.json)—— Go 测试据此做逐维冻结回归,且「该目录是哪次导出的」从文件本身可追溯; - --verify-only 校验既有产物不重新导出,可用来确认线上在用的图没坏。 关键实测结论(已写入 docs/zh/multimodal-space.md 与长期记忆): 原生多帧视频不可行——Qwen3-VL 视觉塔把 grid_thw 当 Python 值消费 (grid_thw.tolist()),legacy tracer 固化为常量,导出后图中根本没有 grid_thw 输入,换帧数调用直接 Invalid input name: grid_thw。故视觉塔固定 (1,48,48), 视频由上层抽帧后逐帧按图像编码(同模型/同维度/同 fingerprint),音频明确 unsupported。 二、模态边界(vector.ErrModalityUnsupported) - 新增 vector.ErrModalityUnsupported:表示「该模态不在本统一空间的原生覆盖 范围内」,与普通错误语义不同——调用方应把它当「永远不会有向量」而非 「本次失败、下次重试」; - qwen.EmbedImageDense 按 mime 拒绝 audio/* 与 video/*:此前它会拿视觉塔 去解音频字节,往统一空间灌入语义错误的坐标且静默; - reembedStaleMedia 对 ErrModalityUnsupported 不计失败、不重试、不用别的 模型向量顶替(TestReembedStaleMedia_SkipsUnsupportedWithoutFaking 守住)。 三、Go ONNX 测试首次完整通过 - 重写 embedder_onnx_test.go:修复编译 + 文本冻结回归 + 图像冻结回归 + 两条阴性对照(不同输入必须不同、图像与文本必须不同)+ 不支持模态断言; - 参考值从产物目录的 qwen_reference.json 读取(不在测试里硬编码浮点); - 用线上部署产物实测全部通过(text cos=0.999999940, image cos=0.999999762)。 四、.gitignore 修复 - /scripts/ 此前被列在「运行时产物」下,但它是作者维护的工具目录 (模型导出、侧车、部署校验),deploy/systemd/embed-sidecar.service 直接 引用 scripts/embed_sidecar.py,忽略它会让那份 unit 在别人的机器上指向 不存在的文件。改为只忽略 __pycache__。 五、文档(docs/zh/multimodal-space.md) - 获取/启用/产物契约/模态边界/验证/资源成本/与现有部署产物的等价性。 验证:go build ./...、go vet ./...、go vet -tags onnxruntime ./...、 go test -short 全部通过;ONNX 标签测试对线上部署产物全部通过。
This commit is contained in:
153
docs/zh/multimodal-space.md
Normal file
153
docs/zh/multimodal-space.md
Normal file
@ -0,0 +1,153 @@
|
||||
# 统一多模态向量空间(Qwen3-VL-Embedding-2B)
|
||||
|
||||
文本、图像、**视频帧** 在同一模型、同一 2048 维、同一 fingerprint 空间里被编码。
|
||||
记忆系统用它做三件事:多模态图记忆的跨模态召回、multimodal doc 的向量融合、
|
||||
multimodal context 的相关性裁剪/淘汰。
|
||||
|
||||
统一空间取代了此前「把图片交给视觉模型生成文字描述、再按描述检索」的做法。
|
||||
那条链路有三个致命缺陷:描述是异步生成的(未生成前媒体等于不存在)、语义检索
|
||||
实际上只搜描述文字、图库里的「媒体节点」只是描述文本的投影而不是媒体本身。
|
||||
**不要再引入任何描述式索引。**
|
||||
|
||||
## 一、产物与获取
|
||||
|
||||
产物约 8 GB(含外部权重),**不进仓库**;用导出脚本自动拉取模型并导出:
|
||||
|
||||
```bash
|
||||
# 自动拉取(HuggingFace 优先,失败回落 ModelScope)+ 导出 + 自检
|
||||
python3 scripts/export_qwen3vl_embedding_onnx.py \
|
||||
--out /home/newqqagent/models/qwen3-vl-embed-multimodal-onnx
|
||||
|
||||
# 已下载过模型:跳过拉取
|
||||
python3 scripts/export_qwen3vl_embedding_onnx.py \
|
||||
--model-dir /path/to/Qwen3-VL-Embedding-2B \
|
||||
--out /home/newqqagent/models/qwen3-vl-embed-multimodal-onnx
|
||||
|
||||
# 参考向量默认直接写进产物目录(<out>/qwen_reference.json),无需额外参数
|
||||
python3 scripts/export_qwen3vl_embedding_onnx.py --model-dir ... --out ...
|
||||
```
|
||||
|
||||
国内镜像:导出脚本沿用 `huggingface_hub` 的约定,直接 `export HF_ENDPOINT=https://hf-mirror.com` 即可。
|
||||
依赖:`torch`(CPU 版即可)、`transformers>=4.57`、`onnx`、`onnxruntime`、`pillow`、`numpy`,
|
||||
以及可选的 `huggingface_hub` / `modelscope`。显存不需要,内存建议 ≥ 16 GB(FP32 加载约 8 GB)。
|
||||
|
||||
导出脚本**会清空 --out 目录**后重写,避免旧图/旧外部权重污染 fingerprint
|
||||
(fingerprint 变化会触发一次无意义的全量向量重算)。因此不要直接覆盖线上正在使用的目录,
|
||||
先导出到新目录再切换。
|
||||
|
||||
### 产物契约(Go 侧按此读取)
|
||||
|
||||
| 文件 | 输入 | 输出 |
|
||||
|---|---|---|
|
||||
| `TokenEmbedding.onnx` | `input_ids` int64 `[1,seq]` | `hidden` float `[1,seq,2048]` |
|
||||
| `Transformer.onnx` | `hidden`、`deepstack_0/1/2` `[1,seq,2048]`、`rotary_cos/sin` `[1,seq,128]`、`causal_mask` `[1,1,seq,seq]` | `embedding` `[1,2048]` |
|
||||
| `Vision.onnx(+.data)` | `pixel_values` `[2304,1536]` | `deepstack_feature_0/1/2`、`vision_hidden_states` `[576,2048]` |
|
||||
|
||||
外加 `tokenizer.json`、`tokenizer_config.json`、`chat_template.jinja`、`embed_config.json`。
|
||||
|
||||
三段只是部署形式,不是三个向量空间:图文共用同一 token embedding、同一 28 层
|
||||
Transformer、同一 last-token 池化。RoPE 与视觉特征散射故意留在 Go 计算,
|
||||
因为旧式 tracer 会把 `seq=598 / visual=576` 烘焙进图里——签名上写着 dynamic
|
||||
axis,实际却只能用导出的那个长度运行。
|
||||
|
||||
### 导出脚本自检(不可省)
|
||||
|
||||
脚本内部跑两道校验,任一道 cos < 0.999999 就以非零码退出:
|
||||
|
||||
1. 分段 PyTorch(三段组合)对比完整模型前向;
|
||||
2. 用 onnxruntime 跑**导出后**的三段图,再对比完整模型前向。
|
||||
|
||||
「能加载」不等于「算得对」:形状错、输入名错、池化位置错的图都能正常 load。
|
||||
|
||||
## 二、启用
|
||||
|
||||
```bash
|
||||
# 配置库(config.db)或 WebUI 设置页
|
||||
core.memory.multimodal_space.type = onnx
|
||||
core.memory.multimodal_space.onnx.model_dir = /home/newqqagent/models/qwen3-vl-embed-multimodal-onnx
|
||||
```
|
||||
|
||||
注意事项:
|
||||
|
||||
- `homed` 必须带 `onnxruntime` build tag 构建,且 `libonnxruntime.so` 可被找到
|
||||
(`/opt/onnxruntime/libonnxruntime.so` 等)。未带 tag 时 `qwen` 是 no-op stub。
|
||||
- 改配置后需重启进程生效。
|
||||
- 未配置时优雅降级:文档层退到 TF-IDF 稀疏检索,媒体块仍按结构边关联,只是没有跨模态召回。
|
||||
|
||||
## 三、模态覆盖范围
|
||||
|
||||
| 模态 | 状态 | 说明 |
|
||||
|---|---|---|
|
||||
| 文本 | ✅ 原生 | `VectorizeDense` |
|
||||
| 图像 | ✅ 原生 | `EmbedImageDense`,固定 768×768 视觉塔 |
|
||||
| 视频 | ⚠️ 逐帧 | 上层抽帧后**逐帧按图像编码**,同模型/同维度/同 fingerprint;不做跨帧时序注意力 |
|
||||
| 音频 | ❌ 明确不支持 | 返回 `vector.ErrModalityUnsupported` |
|
||||
|
||||
**音频不得用视觉塔硬编码**,也**不得**拿另一个模型的向量顶替——那会把两套坐标系
|
||||
混进同一空间,检索出的相似度没有任何意义,而且错误是静默的。未来接入真正的统一
|
||||
音频模型后再扩展。
|
||||
|
||||
### 为什么视频不做原生时序(已实测,勿重复尝试)
|
||||
|
||||
Qwen3-VL 视觉塔把 `grid_thw` 当 Python 值消费(源码里是 `grid_thw.tolist()`)。
|
||||
legacy tracer(`dynamo=False`)会把它固化成常量:实测导出后 ONNX 图里**根本没有**
|
||||
`grid_thw` 输入,用别的帧数调用直接报 `Invalid input name: grid_thw`;
|
||||
导出时的 TracerWarning 明确提示 `Converting a tensor to a Python list might cause
|
||||
the trace to be incorrect`。
|
||||
|
||||
因此视觉塔固定 `grid=(1,48,48)`。要做到原生多帧需要换 `torch.export`/dynamo 路径,
|
||||
而该路径此前已产生过「形状看似动态、实际错误」的静默故障(Core.onnx 的
|
||||
`3 by 23 / 3 by 598` 广播错误),在时序维度上重试的收益不足以抵消风险。
|
||||
视频价值由「逐帧进入同一空间」提供:帧是真实媒体块,按自己的向量被召回。
|
||||
|
||||
## 四、验证
|
||||
|
||||
```bash
|
||||
# Go 侧:ONNX 路径(模型目录缺失时自动 skip)
|
||||
QWEN_ONNX_MODEL_DIR=/home/newqqagent/models/qwen3-vl-embed-multimodal-onnx \
|
||||
go test -tags onnxruntime ./internal/memory/qwen/ -v
|
||||
|
||||
# 排除二进制交付问题的替代:先单独验证模型与 CSV 无关的 ONNX 图
|
||||
go vet -tags onnxruntime ./...
|
||||
```
|
||||
|
||||
Go 测试覆盖:冻结参考向量(文本/图像各 12 维)、同输入确定性、不同输入敏感性、
|
||||
图像与文本向量必须不同、以及音频/视频必须返回 `ErrModalityUnsupported`。
|
||||
|
||||
冻结参考向量由导出脚本写入**产物目录本身**(`<out>/qwen_reference.json`),
|
||||
来源可追溯:同一脚本既产出模型,也产出「这个模型对固定输入应有的输出」。
|
||||
重新导出后若参考值变化,说明权重或图结构变了,必须显式更新参考而不是放宽阈值。
|
||||
|
||||
> **参考向量是 L2 归一化后的值。** ONNX 图返回的是 final norm 之后的原始
|
||||
> last hidden(量级约 100),而 Go 侧 `VectorizeDense` / `EmbedImageDense`
|
||||
> 返回归一化向量。写参考时忘归一化,Go 测试会全线不匹配,而现象看起来
|
||||
> 像“模型不对”,实际只是两边对“向量”的定义不同。
|
||||
|
||||
验证既有产物(不重新导出):
|
||||
|
||||
```bash
|
||||
python3 scripts/export_qwen3vl_embedding_onnx.py --verify-only --model-dir <model> \
|
||||
--out /home/newqqagent/models/qwen3-vl-embed-multimodal-onnx
|
||||
```
|
||||
|
||||
脚本会顺便把归一化后的参考向量写入该目录。
|
||||
|
||||
### 与现有部署产物的等价性
|
||||
|
||||
本仓库脚本对同一源模型导出时,`TokenEmbedding.onnx` 与 `Transformer.onnx` 与
|
||||
线上在用的产物**逐字节相同**(sha256 一致);`Vision.onnx` 差异仅在打包形式:
|
||||
旧产物把权重量到外部 `Vision.onnx.data`,新脚本内联在图里。两者数值等价。
|
||||
|
||||
注意这会带来一个**操作性**差异:Go 的结构指纹(`computeFingerprint`)把
|
||||
`*.onnx.data` 的文件名与大小算在内,因此「外部权重版 ↔ 内联版」互换会让
|
||||
fingerprint 变化,从而触发一次全量向量重算。重算不会**算错**(数值等价),
|
||||
只是白花一次 CPU;若不想触发,就保持产物打包形式不变。
|
||||
|
||||
## 五、资源成本
|
||||
|
||||
- 产物磁盘约 8 GB;导出过程峰值内存约 10–12 GB(FP32 加载)。
|
||||
- 单次 CPU 推理:文本约几十毫秒量级,图像(2304 patch 过 24 层视觉塔 + 28 层语言模型)
|
||||
明显更重,因此入库时不阻塞对话,靠 `reembedStaleMedia` 在启动时并发迁移
|
||||
(ONNX 路径 4 worker)。
|
||||
- fingerprint 由三段图 + `embed_config.json` + 外部权重文件名/大小共同决定;
|
||||
换模型或重新导出都会让它变化,从而触发历史向量重算——这是预期行为。
|
||||
Reference in New Issue
Block a user