Files
2026-10-23 18:08:46 +08:00

6.2 KiB

local-embedding

本地嵌入(embedding)服务。完整替代 Ollama —— 零 API Key、文档不出内网, 对外提供 OpenAI 兼容的 POST /v1/embeddings。

为什么不用 Ollama

给 PandaWiki 做本地化部署时,嵌入模型 本来是走 Ollama 的,但为了这一件事装了一整套 Ollama,代价有三:

  1. Ollama 不归你管 —— 它由 fnOS 应用中心的 ai_installer 托管, 进程和监听地址都不在自己的控制范围内;
  2. 官方 registry 不可达 —— registry.ollama.ai 的 blob 下载会完全卡死 (实测 30 分钟零进度),hf-mirror.com 也连不上,最后只能绕道 ModelScope;
  3. 需要一层转发 —— Ollama 只监听 127.0.0.1,容器访问不到,得额外加一个 socat 容器把 docker 网关转发过去。

这个服务把这三件事都消掉了:一个容器、一个镜像、启动时自动下模型。

与 Ollama 的实测差异

同一批文本,本服务(ONNX int8)与 Ollama(bge-m3 GGUF Q8_0)的向量余弦相似度:

文本 相似度
如何部署 PandaWiki 知识库 0.9848
PandaWiki 支持 Docker 部署,需要先配置向量模型 0.9821
今天上证指数涨跌如何 0.9849

内部相对关系也一致(0.7286 / 0.7390、0.3663 / 0.3664)。差异来自量化方式不同, 属于预期范围,不是实现偏差。维度同为 1024。

快速开始

docker compose up -d

首次启动会下载约 563MB 模型到 ./models:

[local-embedding] 模型目录: /models
  [model_int8.onnx] 0.0B / 542.1MB
  [model_int8.onnx] 542.1MB / 542.1MB (100%)
  [model_int8.onnx] 校验通过
[local-embedding] 模型准备完成

之后重启或重建容器都是零下载:

[model_int8.onnx] 已存在且校验通过 (542.1MB)
[local-embedding] 模型已就绪,跳过下载

服务监听 63002:

curl -s localhost:63002/health
# {"status":"ok","model":"bge-m3"}

curl -s -X POST localhost:63002/v1/embeddings \
  -H 'Content-Type: application/json' \
  -d '{"model":"bge-m3","input":["如何部署知识库","今天天气不错"]}'

设计要点

池化:CLS + L2 归一化

ONNX 导出的第一个输出可能是 [batch, hidden](已池化)也可能是 [batch, seq, hidden](token 级),两种都兼容;后者取 [CLS](第 0 个 token)。

bge 系列官方推荐 CLS 池化 + L2 归一化,归一化后余弦相似度与内积等价, 调用方随便用哪种度量都对。实测输出 L2 范数为 1.000000。

模型不在镜像里,启动时按需下载

download_model.py 在 entrypoint 里先跑,做三件事:

  1. 检查 —— 每个文件比对 大小 + sha256,全通过就立刻返回,零网络请求
  2. 续传 —— 下到 .part,重试带 Range 头从断点继续
  3. 原子替换 —— 校验通过才 os.replace 成正式文件, 避免中断留下的半个文件被当成有效模型加载

还有磁盘预检:空间不够直接报明确错误,而不是刷一片 no space。

为什么选 int8

带 AVX512-VNNI 的 CPU 上 int8 推理能直接吃到指令集加速。542MB 的 int8 vs 2.1GB 的 fp32,体积差 4 倍,精度损失很小 —— 见上面的对比数据。

注意 onnx/model.onnx 只有 0.6MB,它的权重在外置的 model.onnx_data(2.1GB)里; model_int8.onnx 是自包含的单文件,少一个踩坑点。

接口

POST /v1/embeddings

OpenAI 标准。请求:

{
  "model": "bge-m3",
  "input": ["文本1", "文本2"],
  "encoding_format": "float"
}

input 可以是单个字符串、字符串数组,或 token id 数组(三种可混排)。 响应:

{
  "object": "list",
  "data": [
    {"object": "embedding", "index": 0, "embedding": [0.0123, ...]}
  ],
  "model": "bge-m3",
  "usage": {"prompt_tokens": 7, "total_tokens": 7},
  "_meta": {"latency_ms": 141, "dimension": 1024}
}

_meta 是本服务额外加的非标准字段,方便排查延迟和维度,不影响兼容性。

同时接受 /embeddings、/v1/embed、/embed。

其它

  • GET /health —— 存活探测
  • GET /info —— 当前模型、输入输出张量名、参数
  • GET /v1/models —— 兼容部分客户端的启动探测

配置

全部写在 docker-compose.yml 的 environment 里,改完 docker compose up -d 生效。

变量 默认 说明
宿主机端口 63002 compose 的 ports,映射到容器 8000
MODEL_DIR /models 容器内模型目录
MODEL_REPO Xenova/bge-m3 换模型改这里
MODEL_BASE ModelScope 换下载源改这里
ONNX_FILE model_int8.onnx 换量化档位
MAX_LEN 512 截断长度,bge-m3 原生支持 8192,但 CPU 上长序列很慢
BATCH_SIZE 16 每批条数
WORKERS 1 调大前先看内存,每个 worker 各加载一份模型

同仓库可选量化档位:model_fp16.onnx(1081MB)、model_q4f16.onnx(668MB)、 model_q4.onnx(1190MB)。

实测性能

MacBook(Docker Desktop,arm64 模拟)与 i7-1165G7:

场景 耗时
40 条批量 449 ms(约 90 条/秒)
5 条批量 141 ms
镜像大小 401 MB
模型大小 563 MB

已知限制

  • 单 worker:默认 --workers 1,每个 worker 会各加载一份模型(约 2GB 内存峰值)。 高并发应调 WORKERS,同时注意内存翻倍。
  • 不鉴权:不校验 api_key。放到非受信网络需自行加反向代理。
  • 仅 CPU:未接 ONNXRuntime-GPU。
  • 只支持 ONNX:换 safetensors 需改 MANIFEST 并引入 torch。
  • 长文本默认截断到 512:bge-m3 能力上限是 8192,但 CPU 上超过 512 明显变慢。 需要处理长文档时调 MAX_LEN 并实测延迟。

文件

local-embedding/
├── app.py               FastAPI 服务(OpenAI 兼容)
├── download_model.py    模型检查/下载/校验(幂等,支持续传)
├── entrypoint.sh        先下模型,再起 uvicorn
├── requirements.txt     运行时依赖(无 torch)
├── Dockerfile
├── docker-compose.yml   参数都在这,端口 63002
├── README.md
└── .gitignore

配套的 local-remarker 是同一套思路的重排服务, 接口为 SiliconFlow 格式。两个服务可以独立部署,也可以放在同一个 compose 里。