Files
HomeAgent/docs/zh/multimodal-space.md
JianFeeeee 1a02971f88 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 标签测试对线上部署产物全部通过。
2026-09-11 13:45:25 +08:00

8.0 KiB
Raw Blame History

统一多模态向量空间(Qwen3-VL-Embedding-2B)

文本、图像、视频帧 在同一模型、同一 2048 维、同一 fingerprint 空间里被编码。 记忆系统用它做三件事:多模态图记忆的跨模态召回、multimodal doc 的向量融合、 multimodal context 的相关性裁剪/淘汰。

统一空间取代了此前「把图片交给视觉模型生成文字描述、再按描述检索」的做法。 那条链路有三个致命缺陷:描述是异步生成的(未生成前媒体等于不存在)、语义检索 实际上只搜描述文字、图库里的「媒体节点」只是描述文本的投影而不是媒体本身。 不要再引入任何描述式索引。

一、产物与获取

产物约 8 GB(含外部权重),不进仓库;用导出脚本自动拉取模型并导出:

# 自动拉取(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。

二、启用

# 配置库(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 广播错误),在时序维度上重试的收益不足以抵消风险。 视频价值由「逐帧进入同一空间」提供:帧是真实媒体块,按自己的向量被召回。

四、验证

# 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 测试会全线不匹配,而现象看起来 像“模型不对”,实际只是两边对“向量”的定义不同。

验证既有产物(不重新导出):

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 + 外部权重文件名/大小共同决定; 换模型或重新导出都会让它变化,从而触发历史向量重算——这是预期行为。