first commit
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
# 模型权重目录(约 570MB,不进版本库)
|
||||
models/
|
||||
*.onnx
|
||||
*.part
|
||||
|
||||
__pycache__/
|
||||
*.pyc
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
# 本地嵌入服务镜像。
|
||||
#
|
||||
# 模型不在构建期打包 —— 体积大(约 570MB)且会频繁升级,改为容器启动时按需下载,
|
||||
# 已有则复用。这样换模型只需改 compose 里的参数后重启,不用重新 build。
|
||||
FROM python:3.11-slim
|
||||
|
||||
# pip 源做成构建参数:国内机器可用清华源显著提速
|
||||
ARG PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple
|
||||
ARG PIP_TRUSTED_HOST=pypi.tuna.tsinghua.edu.cn
|
||||
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
PIP_DISABLE_PIP_VERSION_CHECK=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir \
|
||||
-i "${PIP_INDEX_URL}" \
|
||||
--trusted-host "${PIP_TRUSTED_HOST}" \
|
||||
-r requirements.txt
|
||||
|
||||
COPY app.py download_model.py entrypoint.sh ./
|
||||
RUN chmod +x entrypoint.sh
|
||||
|
||||
# 模型目录。挂卷到这里,重启容器时模型不会丢。
|
||||
ENV MODEL_DIR=/models
|
||||
RUN mkdir -p /models
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
# slim 镜像里没有 curl,直接用 python 做健康检查
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=900s --retries=3 \
|
||||
CMD python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/health',timeout=4).status==200 else 1)"
|
||||
|
||||
CMD ["./entrypoint.sh"]
|
||||
@@ -0,0 +1,189 @@
|
||||
# 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 里。
|
||||
@@ -0,0 +1,214 @@
|
||||
"""本地嵌入(embedding)服务。
|
||||
|
||||
对外提供 OpenAI 兼容的 `POST /v1/embeddings`,任何支持自定义 OpenAI 兼容端点
|
||||
的框架都能直接对接,不需要改 SDK。
|
||||
|
||||
与 local-remarker 的区别:
|
||||
* 端点是 OpenAI 标准(而非 SiliconFlow 私有格式),调用方不会拼错路径;
|
||||
* 后处理是 CLS 池化 + L2 归一化(bge 系列官方做法),而不是取 logit;
|
||||
* 完全不依赖 Ollama —— 模型以 ONNX 形式直接跑在 onnxruntime 上。
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import onnxruntime as ort
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from tokenizers import Tokenizer
|
||||
|
||||
# ---------------------------------------------------------------- 配置
|
||||
MODEL_DIR = os.environ.get("MODEL_DIR", "/models")
|
||||
ONNX_FILE = os.environ.get("ONNX_FILE", "model_int8.onnx")
|
||||
TOKENIZER_FILE = os.environ.get("TOKENIZER_FILE", "tokenizer.json")
|
||||
MODEL_NAME = os.environ.get("MODEL_NAME", "bge-m3")
|
||||
# bge-m3 原生支持 8192 上下文,但 CPU 上长序列非常慢,默认按 512 截断
|
||||
MAX_LEN = int(os.environ.get("MAX_LEN", "512"))
|
||||
BATCH_SIZE = int(os.environ.get("BATCH_SIZE", "16"))
|
||||
PAD_ID = int(os.environ.get("PAD_ID", "1"))
|
||||
NUM_THREADS = os.environ.get("NUM_THREADS", "") or None
|
||||
|
||||
app = FastAPI(title="local-embedding", version="1.0")
|
||||
|
||||
|
||||
def _build_session() -> tuple[ort.InferenceSession, Tokenizer]:
|
||||
onnx_path = os.path.join(MODEL_DIR, ONNX_FILE)
|
||||
tok_path = os.path.join(MODEL_DIR, TOKENIZER_FILE)
|
||||
for path in (onnx_path, tok_path):
|
||||
if not os.path.isfile(path):
|
||||
raise FileNotFoundError(
|
||||
f"缺少模型文件: {path}(entrypoint 应先执行 download_model.py)"
|
||||
)
|
||||
|
||||
options = ort.SessionOptions()
|
||||
options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
|
||||
options.intra_op_num_threads = int(NUM_THREADS) if NUM_THREADS else (os.cpu_count() or 4)
|
||||
|
||||
session = ort.InferenceSession(
|
||||
onnx_path, sess_options=options, providers=["CPUExecutionProvider"]
|
||||
)
|
||||
|
||||
tokenizer = Tokenizer.from_file(tok_path)
|
||||
tokenizer.enable_truncation(max_length=MAX_LEN)
|
||||
return session, tokenizer
|
||||
|
||||
|
||||
SESSION, TOKENIZER = _build_session()
|
||||
INPUT_NAMES = {i.name for i in SESSION.get_inputs()}
|
||||
OUTPUT_NAMES = [o.name for o in SESSION.get_outputs()]
|
||||
|
||||
|
||||
def _pool(outputs: list[np.ndarray]) -> np.ndarray:
|
||||
"""从 ONNX 输出取句向量并做 L2 归一化。
|
||||
|
||||
不同导出方式的第一个输出形状不一致,两种都兼容:
|
||||
* [batch, hidden] —— 已池化好的句向量
|
||||
* [batch, seq, hidden] —— token 级输出,取 [CLS](第 0 个 token)
|
||||
|
||||
bge 系列官方推荐 CLS 池化 + L2 归一化,归一化后余弦相似度与内积等价。
|
||||
"""
|
||||
hidden = outputs[0]
|
||||
if hidden.ndim == 3:
|
||||
hidden = hidden[:, 0, :] # [CLS]
|
||||
norms = np.linalg.norm(hidden, axis=1, keepdims=True)
|
||||
norms[norms == 0] = 1.0
|
||||
return hidden / norms
|
||||
|
||||
|
||||
def _forward(rows: list[tuple[list[int], list[int], list[int]]]) -> np.ndarray:
|
||||
"""把 (ids, attention_mask, type_ids) 组成 batch 跑一次推理,返回归一化向量。"""
|
||||
width = max(len(ids) for ids, _, _ in rows)
|
||||
size = len(rows)
|
||||
|
||||
input_ids = np.full((size, width), PAD_ID, dtype=np.int64)
|
||||
attention_mask = np.zeros((size, width), dtype=np.int64)
|
||||
token_type_ids = np.zeros((size, width), dtype=np.int64)
|
||||
|
||||
for row, (ids, mask, types) in enumerate(rows):
|
||||
length = len(ids)
|
||||
input_ids[row, :length] = ids
|
||||
attention_mask[row, :len(mask)] = mask
|
||||
token_type_ids[row, :len(types)] = types
|
||||
|
||||
feeds = {"input_ids": input_ids, "attention_mask": attention_mask}
|
||||
if "token_type_ids" in INPUT_NAMES:
|
||||
feeds["token_type_ids"] = token_type_ids
|
||||
|
||||
return _pool(SESSION.run(None, feeds)).astype(np.float32)
|
||||
|
||||
|
||||
def _encode(texts: list[str], token_ids: dict[int, list[int]]) -> list[tuple]:
|
||||
"""统一编码入口。token_ids 里的下标表示该项已是 token id 序列。"""
|
||||
rows, tokens = [], 0
|
||||
for index, text in enumerate(texts):
|
||||
if index in token_ids:
|
||||
ids = token_ids[index][:MAX_LEN]
|
||||
mask = [1] * len(ids)
|
||||
types = [0] * len(ids)
|
||||
else:
|
||||
encoding = TOKENIZER.encode(text)
|
||||
ids, mask, types = encoding.ids, encoding.attention_mask, encoding.type_ids
|
||||
rows.append((ids, mask, types))
|
||||
tokens += len(ids)
|
||||
return rows, tokens
|
||||
|
||||
|
||||
def _invalid(message: str) -> JSONResponse:
|
||||
return JSONResponse(
|
||||
{"error": {"message": message, "type": "invalid_request_error"}},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
|
||||
async def _handle(request: Request) -> JSONResponse:
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception: # noqa: BLE001
|
||||
return _invalid("invalid json body")
|
||||
|
||||
if "input" not in body:
|
||||
return _invalid("input is required")
|
||||
|
||||
raw = body["input"]
|
||||
items = [raw] if isinstance(raw, str) else raw
|
||||
if not isinstance(items, list) or not items:
|
||||
return _invalid("input must be a non-empty string or array")
|
||||
|
||||
# 允许混排字符串与 token id 数组(OpenAI 规范允许后者)
|
||||
texts, token_ids = [], {}
|
||||
for position, item in enumerate(items):
|
||||
if isinstance(item, str):
|
||||
texts.append(item)
|
||||
elif isinstance(item, list) and item and all(isinstance(x, int) for x in item):
|
||||
token_ids[position] = item
|
||||
texts.append("")
|
||||
else:
|
||||
return _invalid("input items must be strings or token id arrays")
|
||||
|
||||
started = time.time()
|
||||
try:
|
||||
vectors, tokens = [], 0
|
||||
for start in range(0, len(texts), BATCH_SIZE):
|
||||
chunk = texts[start:start + BATCH_SIZE]
|
||||
offset = {k - start: v for k, v in token_ids.items() if start <= k < start + len(chunk)}
|
||||
rows, chunk_tokens = _encode(chunk, offset)
|
||||
vectors.append(_forward(rows))
|
||||
tokens += chunk_tokens
|
||||
matrix = np.concatenate(vectors, axis=0)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return JSONResponse(
|
||||
{"error": {"message": str(exc), "type": "server_error"}}, status_code=500
|
||||
)
|
||||
|
||||
return JSONResponse({
|
||||
"object": "list",
|
||||
"data": [
|
||||
{"object": "embedding", "index": i, "embedding": matrix[i].tolist()}
|
||||
for i in range(matrix.shape[0])
|
||||
],
|
||||
"model": body.get("model") or MODEL_NAME,
|
||||
"usage": {"prompt_tokens": tokens, "total_tokens": tokens},
|
||||
"_meta": {
|
||||
"latency_ms": int((time.time() - started) * 1000),
|
||||
"dimension": int(matrix.shape[1]),
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
# OpenAI 标准路径 + 常见别名
|
||||
for _path in ("/v1/embeddings", "/embeddings", "/v1/embed", "/embed"):
|
||||
app.post(_path)(_handle)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health() -> dict[str, Any]:
|
||||
return {"status": "ok", "model": MODEL_NAME}
|
||||
|
||||
|
||||
@app.get("/info")
|
||||
def info() -> dict[str, Any]:
|
||||
return {
|
||||
"model": MODEL_NAME,
|
||||
"onnx_file": ONNX_FILE,
|
||||
"model_dir": MODEL_DIR,
|
||||
"max_len": MAX_LEN,
|
||||
"batch_size": BATCH_SIZE,
|
||||
"input_names": sorted(INPUT_NAMES),
|
||||
"output_names": OUTPUT_NAMES,
|
||||
}
|
||||
|
||||
|
||||
# 部分客户端启动时会先探测 /v1/models
|
||||
@app.get("/v1/models")
|
||||
def list_models() -> dict[str, Any]:
|
||||
return {
|
||||
"object": "list",
|
||||
"data": [{
|
||||
"id": MODEL_NAME,
|
||||
"object": "model",
|
||||
"created": int(time.time()),
|
||||
"owned_by": "local",
|
||||
}],
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
# 本地嵌入(embedding)服务 —— 完整替代 Ollama。
|
||||
#
|
||||
# 参数全部直接写在本文件里,不再走 .env —— 需要调整时改这里,然后
|
||||
# docker compose up -d 重建即可。
|
||||
#
|
||||
# 首次启动会下载约 570MB 模型权重到 ./models;
|
||||
# 之后重启/重建都会先校验 sha256,通过就跳过下载,零网络流量。
|
||||
|
||||
services:
|
||||
local-embedding:
|
||||
build: .
|
||||
image: local-embedding:latest
|
||||
container_name: local-embedding
|
||||
restart: unless-stopped
|
||||
|
||||
# 宿主机 63002 -> 容器 8000
|
||||
ports:
|
||||
- "63000:8000"
|
||||
|
||||
environment:
|
||||
# ---- 模型存放位置(容器内)----
|
||||
MODEL_DIR: /models
|
||||
|
||||
# ---- 模型来源与选择 ----
|
||||
MODEL_REPO: Xenova/bge-m3
|
||||
MODEL_REV: master
|
||||
MODEL_BASE: https://modelscope.cn/models/Xenova/bge-m3/resolve/master
|
||||
MODEL_NAME: bge-m3
|
||||
# 同仓库可选量化档位:
|
||||
# model_int8.onnx 542MB 默认,带 AVX512-VNNI 的 CPU 上最快
|
||||
# model_fp16.onnx 1081MB
|
||||
# model_q4f16.onnx 668MB
|
||||
# model_q4.onnx 1190MB
|
||||
# 更大的模型可改用 BAAI/bge-m3(原始 fp32,2.2GB,且需额外的 model.onnx_data)
|
||||
ONNX_FILE: model_int8.onnx
|
||||
|
||||
# ---- 推理参数 ----
|
||||
MAX_LEN: "512" # bge-m3 原生支持 8192,但 CPU 上长序列很慢
|
||||
BATCH_SIZE: "16" # 每次推理的条数
|
||||
PAD_ID: "1" # bge-m3 是 XLM-R 架构,padding 用 [PAD]
|
||||
|
||||
# ---- 服务 ----
|
||||
WORKERS: "1" # 调大前先看内存:每个 worker 会各加载一份模型
|
||||
PORT: "8000"
|
||||
LOG_LEVEL: info
|
||||
TZ: Asia/Shanghai
|
||||
|
||||
# 宿主机 ./models -> 容器 /models。指向大盘路径可避免占满系统盘,
|
||||
# 例如改成 /data/models/embedding:/models
|
||||
volumes:
|
||||
- ./models:/models
|
||||
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD
|
||||
- python
|
||||
- -c
|
||||
- "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/health',timeout=4).status==200 else 1)"
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
# 首次启动要下模型,给足预热时间
|
||||
start_period: 900s
|
||||
retries: 3
|
||||
@@ -0,0 +1,201 @@
|
||||
"""启动前检查并下载嵌入模型。
|
||||
|
||||
设计目标:
|
||||
* 幂等 —— 模型已存在且 sha256 校验通过就立刻返回,不产生任何网络请求;
|
||||
* 可续传 —— 弱网下载中断后重启容器能从断点继续;
|
||||
* 可校验 —— 每个文件都带 sha256,下载后校验,不匹配则丢弃重下。
|
||||
|
||||
可配置项(均可用环境变量覆盖):
|
||||
MODEL_DIR 模型存放目录,默认 /models
|
||||
MODEL_REPO 仓库名,默认 Xenova/bge-m3
|
||||
MODEL_BASE 下载源前缀
|
||||
FORCE 设为 1 时忽略本地文件强制重下
|
||||
MAX_RETRIES 单个文件最大重试次数
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
MODEL_DIR = os.environ.get("MODEL_DIR", "/models")
|
||||
MODEL_REPO = os.environ.get("MODEL_REPO", "Xenova/bge-m3")
|
||||
MODEL_REV = os.environ.get("MODEL_REV", "master")
|
||||
MODEL_BASE = os.environ.get(
|
||||
"MODEL_BASE", f"https://modelscope.cn/models/{MODEL_REPO}/resolve/{MODEL_REV}"
|
||||
)
|
||||
FORCE = os.environ.get("FORCE", "0") == "1"
|
||||
MAX_RETRIES = int(os.environ.get("MAX_RETRIES", "5"))
|
||||
TIMEOUT = int(os.environ.get("DOWNLOAD_TIMEOUT", "60"))
|
||||
|
||||
# (目标文件名, 仓库内路径, sha256, 字节数)
|
||||
#
|
||||
# 选 model_int8.onnx:568MB 自包含(没有外置的 .onnx_data),且带 AVX512-VNNI
|
||||
# 的 CPU 上 int8 推理最快。fp32 版需要额外下载 model.onnx_data 共 2.1GB。
|
||||
MANIFEST = [
|
||||
(
|
||||
"model_int8.onnx",
|
||||
"onnx/model_int8.onnx",
|
||||
"a206e10e995aa2a833924bcd725ba5dd6c3425cd34bac3cf2b5677cd2a1c51d6",
|
||||
568456694,
|
||||
),
|
||||
(
|
||||
"tokenizer.json",
|
||||
"tokenizer.json",
|
||||
"6710678b12670bc442b99edc952c4d996ae309a7020c1fa0096dd245c2faf790",
|
||||
17082821,
|
||||
),
|
||||
(
|
||||
"sentencepiece.bpe.model",
|
||||
"sentencepiece.bpe.model",
|
||||
"cfc8146abe2a0488e9e2a0c56de7952f7c11ab059eca145a0a727afce0db2865",
|
||||
5069051,
|
||||
),
|
||||
(
|
||||
"special_tokens_map.json",
|
||||
"special_tokens_map.json",
|
||||
"8c785abebea9ae3257b61681b4e6fd8365ceafde980c21970d001e834cf10835",
|
||||
964,
|
||||
),
|
||||
(
|
||||
"tokenizer_config.json",
|
||||
"tokenizer_config.json",
|
||||
"7e4c1cc848840aeccdd763458c18dd525eb0f795c992e00ebe9c28554e7db2d4",
|
||||
1173,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def human(size: float) -> str:
|
||||
for unit in ("B", "KB", "MB", "GB"):
|
||||
if size < 1024 or unit == "GB":
|
||||
return f"{size:.1f}{unit}"
|
||||
size /= 1024
|
||||
return f"{size:.1f}GB"
|
||||
|
||||
|
||||
def sha256_of(path: str, chunk: int = 1 << 20) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with open(path, "rb") as fh:
|
||||
while True:
|
||||
block = fh.read(chunk)
|
||||
if not block:
|
||||
break
|
||||
digest.update(block)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def is_valid(path: str, expect_sha: str, expect_size: int) -> bool:
|
||||
if not os.path.isfile(path):
|
||||
return False
|
||||
if os.path.getsize(path) != expect_size:
|
||||
return False
|
||||
return sha256_of(path) == expect_sha
|
||||
|
||||
|
||||
def download(name: str, remote_path: str) -> None:
|
||||
"""带断点续传的下载,落到 .part 临时文件,成功后原子改名。"""
|
||||
target = os.path.join(MODEL_DIR, name)
|
||||
part = target + ".part"
|
||||
url = f"{MODEL_BASE}/{remote_path}"
|
||||
|
||||
last_error: Exception | None = None
|
||||
for attempt in range(1, MAX_RETRIES + 1):
|
||||
try:
|
||||
have = os.path.getsize(part) if os.path.isfile(part) else 0
|
||||
total = next((m[3] for m in MANIFEST if m[0] == name), 0)
|
||||
|
||||
request = urllib.request.Request(url)
|
||||
request.add_header("User-Agent", "local-embedding/1.0")
|
||||
if have:
|
||||
request.add_header("Range", f"bytes={have}-")
|
||||
|
||||
with urllib.request.urlopen(request, timeout=TIMEOUT) as resp:
|
||||
# 服务端不支持 Range 时会返回 200 全量,此时从零重写
|
||||
mode = "ab" if (have and resp.status == 206) else "wb"
|
||||
if mode == "wb":
|
||||
have = 0
|
||||
downloaded = have
|
||||
|
||||
print(f" [{name}] {human(downloaded)} / {human(total)}", flush=True)
|
||||
with open(part, mode) as out:
|
||||
while True:
|
||||
block = resp.read(1 << 20)
|
||||
if not block:
|
||||
break
|
||||
out.write(block)
|
||||
downloaded += len(block)
|
||||
pct = downloaded * 100 // total if total else 0
|
||||
print(
|
||||
f"\r [{name}] {human(downloaded)} / {human(total)} ({pct}%)",
|
||||
end="",
|
||||
flush=True,
|
||||
)
|
||||
print(flush=True)
|
||||
|
||||
os.replace(part, target)
|
||||
return
|
||||
|
||||
except (urllib.error.URLError, OSError, TimeoutError) as exc:
|
||||
last_error = exc
|
||||
wait = min(2 ** attempt, 30)
|
||||
print(
|
||||
f"\n [{name}] 第 {attempt}/{MAX_RETRIES} 次失败: {exc},{wait}s 后重试",
|
||||
flush=True,
|
||||
)
|
||||
if attempt < MAX_RETRIES:
|
||||
time.sleep(wait)
|
||||
|
||||
raise SystemExit(f"下载 {name} 失败: {last_error}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print(f"[local-embedding] 模型目录: {MODEL_DIR}")
|
||||
print(f"[local-embedding] 下载源 : {MODEL_BASE}")
|
||||
|
||||
os.makedirs(MODEL_DIR, exist_ok=True)
|
||||
|
||||
pending = []
|
||||
for name, remote, sha, size in MANIFEST:
|
||||
target = os.path.join(MODEL_DIR, name)
|
||||
if FORCE or not is_valid(target, sha, size):
|
||||
if not FORCE and os.path.isfile(target):
|
||||
print(f" [{name}] 校验不通过,将重新下载")
|
||||
pending.append((name, remote, sha, size))
|
||||
else:
|
||||
print(f" [{name}] 已存在且校验通过 ({human(size)})")
|
||||
|
||||
if not pending:
|
||||
print("[local-embedding] 模型已就绪,跳过下载")
|
||||
return 0
|
||||
|
||||
total_bytes = sum(item[3] for item in pending)
|
||||
print(f"[local-embedding] 需要下载 {len(pending)} 个文件,共 {human(total_bytes)}")
|
||||
|
||||
# 容器内磁盘常较小,给个明确提示而不是让下载器报一堆 no space
|
||||
free = shutil.disk_usage(MODEL_DIR).free
|
||||
if free < total_bytes * 1.1:
|
||||
raise SystemExit(
|
||||
f"磁盘空间不足:可用 {human(free)},需要约 {human(total_bytes)}。"
|
||||
"请挂载更大的卷,或改用更小的嵌入模型。"
|
||||
)
|
||||
|
||||
for name, remote, sha, size in pending:
|
||||
download(name, remote)
|
||||
|
||||
# 最终校验,任何文件对不上都不允许带着半个模型启动
|
||||
for name, _remote, sha, size in MANIFEST:
|
||||
target = os.path.join(MODEL_DIR, name)
|
||||
if not is_valid(target, sha, size):
|
||||
raise SystemExit(f"{name} 校验失败,拒绝启动")
|
||||
print(f" [{name}] 校验通过")
|
||||
|
||||
print("[local-embedding] 模型准备完成")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Executable
+17
@@ -0,0 +1,17 @@
|
||||
#!/bin/sh
|
||||
# 容器入口:先确保模型就位,再启动服务。
|
||||
#
|
||||
# download_model.py 是幂等的 —— 模型已存在且 sha256 校验通过时会直接返回,
|
||||
# 因此正常的重启/重建容器不会产生任何下载流量;首次启动才会拉取约 570MB 权重。
|
||||
|
||||
set -e
|
||||
|
||||
echo "[entrypoint] 检查模型 ..."
|
||||
python /app/download_model.py
|
||||
|
||||
echo "[entrypoint] 启动 embedding 服务 ..."
|
||||
exec uvicorn app:app \
|
||||
--host "${HOST:-0.0.0.0}" \
|
||||
--port "${PORT:-8000}" \
|
||||
--workers "${WORKERS:-1}" \
|
||||
--log-level "${LOG_LEVEL:-info}"
|
||||
@@ -0,0 +1,5 @@
|
||||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.34.0
|
||||
onnxruntime==1.20.1
|
||||
tokenizers==0.21.0
|
||||
numpy==2.2.1
|
||||
Reference in New Issue
Block a user