mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-21 01:18:08 +00:00
此前三段式拆分后 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 标签测试对线上部署产物全部通过。
204 lines
6.5 KiB
Python
204 lines
6.5 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Jina v5-omni-nano embedding sidecar for HomeAgent.
|
|
|
|
Minimal HTTP server exposing POST /embed matching the HTTPEmbedder contract:
|
|
Request: {"modality":"text|image", "side":"query|document", "text":"...", "data":"base64...", "mime":"..."}
|
|
Response: {"embedding":[float...]}
|
|
|
|
Health: GET /health → {"status":"ok","model":"jina-v5-omni-nano","dim":768,"loaded":true}
|
|
"""
|
|
|
|
import base64
|
|
import io
|
|
import logging
|
|
import os
|
|
import signal
|
|
import sys
|
|
import threading
|
|
import time
|
|
from http.server import HTTPServer, BaseHTTPRequestHandler
|
|
from urllib.parse import urlparse
|
|
|
|
import numpy as np
|
|
import torch
|
|
|
|
# ─── Config ──────────────────────────────────────────────────────────────────
|
|
MODEL_DIR = os.environ.get("JINA_MODEL_DIR", "/home/newqqagent/models/jina-v5-omni-nano")
|
|
PORT = int(os.environ.get("JINA_PORT", "18999"))
|
|
DIMENSION = int(os.environ.get("JINA_DIMENSION", "768"))
|
|
MAX_WORKERS = int(os.environ.get("JINA_MAX_WORKERS", "4"))
|
|
BATCH_SIZE = int(os.environ.get("JINA_BATCH_SIZE", "8"))
|
|
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format="%(asctime)s [embed_sidecar] %(message)s",
|
|
datefmt="%Y-%m-%d %H:%M:%S",
|
|
)
|
|
log = logging.getLogger("embed_sidecar")
|
|
|
|
# ─── Model Loading ───────────────────────────────────────────────────────────
|
|
model = None
|
|
processor = None
|
|
model_lock = threading.Lock()
|
|
ready = False
|
|
|
|
|
|
def load_model():
|
|
global model, processor, ready
|
|
log.info("loading model from %s ...", MODEL_DIR)
|
|
t0 = time.time()
|
|
|
|
from transformers import AutoModel, AutoProcessor
|
|
|
|
model = AutoModel.from_pretrained(
|
|
MODEL_DIR,
|
|
trust_remote_code=True,
|
|
local_files_only=True,
|
|
default_task="retrieval",
|
|
modality="vision",
|
|
dtype=torch.float32,
|
|
).eval()
|
|
|
|
processor = AutoProcessor.from_pretrained(
|
|
MODEL_DIR,
|
|
trust_remote_code=True,
|
|
local_files_only=True,
|
|
)
|
|
|
|
elapsed = time.time() - t0
|
|
ready = True
|
|
log.info("model loaded in %.1fs, dim=%d", elapsed, DIMENSION)
|
|
|
|
|
|
def embed_text(text: str, side: str = "query") -> list[float]:
|
|
"""Embed text with proper Query:/Document: prefix for retrieval."""
|
|
prefix = "Query: " if side == "query" else "Document: "
|
|
inputs = processor(
|
|
text=[prefix + text],
|
|
padding=True,
|
|
truncation=True,
|
|
max_length=1024,
|
|
return_tensors="pt",
|
|
)
|
|
with torch.inference_mode():
|
|
vec = model.embed(**inputs)
|
|
return vec.float().cpu().numpy()[0].tolist()
|
|
|
|
|
|
def embed_image(data_b64: str, mime: str, side: str = "document") -> list[float]:
|
|
"""Embed image from base64 data."""
|
|
from PIL import Image
|
|
|
|
img_bytes = base64.b64decode(data_b64)
|
|
img = Image.open(io.BytesIO(img_bytes)).convert("RGB")
|
|
|
|
prefix = "Query: " if side == "query" else "Document: "
|
|
inputs = processor(
|
|
images=img,
|
|
text=f"{prefix}<image>",
|
|
return_tensors="pt",
|
|
)
|
|
with torch.inference_mode():
|
|
vec = model.embed(**inputs)
|
|
return vec.float().cpu().numpy()[0].tolist()
|
|
|
|
|
|
# ─── HTTP Server ─────────────────────────────────────────────────────────────
|
|
class EmbedHandler(BaseHTTPRequestHandler):
|
|
"""Handle /embed and /health endpoints."""
|
|
|
|
def log_message(self, fmt, *args):
|
|
# Suppress default access log for /health
|
|
if "/health" not in str(args[0]):
|
|
log.info(fmt, *args)
|
|
|
|
def do_GET(self):
|
|
parsed = urlparse(self.path)
|
|
if parsed.path == "/health":
|
|
self._respond(200, {
|
|
"status": "ok" if ready else "loading",
|
|
"model": "jina-v5-omni-nano",
|
|
"dim": DIMENSION,
|
|
"loaded": ready,
|
|
})
|
|
else:
|
|
self._respond(404, {"error": "not found"})
|
|
|
|
def do_POST(self):
|
|
parsed = urlparse(self.path)
|
|
if parsed.path != "/embed":
|
|
self._respond(404, {"error": "not found"})
|
|
return
|
|
|
|
if not ready:
|
|
self._respond(503, {"error": "model not loaded"})
|
|
return
|
|
|
|
# Read request body
|
|
try:
|
|
length = int(self.headers.get("Content-Length", 0))
|
|
body = self.rfile.read(length)
|
|
req = __import__("json").loads(body)
|
|
except Exception as e:
|
|
self._respond(400, {"error": f"invalid request: {e}"})
|
|
return
|
|
|
|
modality = req.get("modality", "text")
|
|
side = req.get("side", "query")
|
|
|
|
try:
|
|
with model_lock:
|
|
if modality == "text":
|
|
text = req.get("text", "")
|
|
if not text:
|
|
self._respond(400, {"error": "missing text field"})
|
|
return
|
|
vec = embed_text(text, side)
|
|
elif modality == "image":
|
|
data = req.get("data", "")
|
|
mime = req.get("mime", "image/png")
|
|
if not data:
|
|
self._respond(400, {"error": "missing data field"})
|
|
return
|
|
vec = embed_image(data, mime, side)
|
|
else:
|
|
self._respond(400, {"error": f"unsupported modality: {modality}"})
|
|
return
|
|
|
|
self._respond(200, {"embedding": vec})
|
|
except Exception as e:
|
|
log.error("embed error: %s", e, exc_info=True)
|
|
self._respond(500, {"error": str(e)})
|
|
|
|
def _respond(self, status: int, data: dict):
|
|
import json as json_mod
|
|
body = json_mod.dumps(data).encode("utf-8")
|
|
self.send_response(status)
|
|
self.send_header("Content-Type", "application/json")
|
|
self.send_header("Content-Length", str(len(body)))
|
|
self.end_headers()
|
|
self.wfile.write(body)
|
|
|
|
|
|
def main():
|
|
# Load model in background thread so server can start accepting /health
|
|
threading.Thread(target=load_model, daemon=True).start()
|
|
|
|
server = HTTPServer(("0.0.0.0", PORT), EmbedHandler)
|
|
|
|
def shutdown(signum, frame):
|
|
log.info("shutting down...")
|
|
server.shutdown()
|
|
sys.exit(0)
|
|
|
|
signal.signal(signal.SIGTERM, shutdown)
|
|
signal.signal(signal.SIGINT, shutdown)
|
|
|
|
log.info("listening on :%d", PORT)
|
|
server.serve_forever()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|