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:
JianFeeeee
2026-09-11 13:45:01 +08:00
parent 5836c2ce5c
commit 1a02971f88
11 changed files with 1518 additions and 53 deletions

153
docs/zh/multimodal-space.md Normal file
View 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 GBFP32 加载约 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导出过程峰值内存约 1012 GBFP32 加载)。
- 单次 CPU 推理文本约几十毫秒量级图像2304 patch 24 层视觉塔 + 28 层语言模型
明显更重因此入库时不阻塞对话 `reembedStaleMedia` 在启动时并发迁移
ONNX 路径 4 worker)。
- fingerprint 由三段图 + `embed_config.json` + 外部权重文件名/大小共同决定
换模型或重新导出都会让它变化从而触发历史向量重算——这是预期行为