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

189 lines
6.2 KiB
Markdown

# local-embedding
本地嵌入(embedding)服务。**完整替代 Ollama** —— 零 API Key、文档不出内网,
对外提供 OpenAI 兼容的 `POST /v1/embeddings`。
## 为什么不用 Ollama
给 [PandaWiki](https://github.com/chaitin/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。
## 快速开始
```bash
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**:
```bash
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 标准。请求:
```json
{
"model": "bge-m3",
"input": ["文本1", "文本2"],
"encoding_format": "float"
}
```
`input` 可以是单个字符串、字符串数组,或 token id 数组(三种可混排)。
响应:
```json
{
"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](../local-remarker) 是同一套思路的重排服务,
接口为 SiliconFlow 格式。两个服务可以独立部署,也可以放在同一个 compose 里。