Files
local-remarker/app.py
T
2026-10-23 18:09:59 +08:00

181 lines
6.2 KiB
Python

"""本地重排(rerank)服务。
对外暴露 SiliconFlow 兼容的 `POST /v1/services/rerank/text`,方便直接对接
各类 RAG 框架。之所以自己实现而不是用现成推理框架,主要是因为:
很多推理运行时(如 Ollama)并不提供 rerank 端点。
打分逻辑遵循 bge-reranker 系列的惯例:把 (query, document) 拼成句对批量推理,
取 logits[:, 0] 作为相关性分数(原始 logit,**不过 sigmoid**),降序后取 top_n。
"""
import os
import time
import uuid
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-reranker-base")
MAX_LEN = int(os.environ.get("MAX_LEN", "512"))
BATCH_SIZE = int(os.environ.get("BATCH_SIZE", "16"))
NUM_THREADS = os.environ.get("NUM_THREADS", "") or None
app = FastAPI(title="local-remarker", 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
if NUM_THREADS:
options.intra_op_num_threads = int(NUM_THREADS)
else:
# 默认吃满可用核;AVX512-VNNI 的机器上 int8 推理收益明显
options.intra_op_num_threads = 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)
tokenizer.enable_padding(pad_id=1, pad_token="[PAD]")
return session, tokenizer
SESSION, TOKENIZER = _build_session()
INPUT_NAMES = {i.name for i in SESSION.get_inputs()}
def _score(query: str, documents: list[str]) -> list[float]:
"""对 (query, doc) 句对批量打分,返回与 documents 等长的分数列表。"""
encodings = TOKENIZER.encode_batch([[query, doc] for doc in documents])
input_ids = np.array([e.ids for e in encodings], dtype=np.int64)
attention_mask = np.array([e.attention_mask for e in encodings], dtype=np.int64)
feeds = {"input_ids": input_ids, "attention_mask": attention_mask}
if "token_type_ids" in INPUT_NAMES:
feeds["token_type_ids"] = np.array([e.type_ids for e in encodings], dtype=np.int64)
logits = SESSION.run(None, feeds)[0]
# bge-reranker 只有 1 个 logit,取第 0 列即相关性分数(原始 logit)
return [float(v) for v in np.asarray(logits).reshape(len(documents), -1)[:, 0]]
def _rerank(query: str, documents: list[str], top_n: int,
return_documents: bool) -> dict[str, Any]:
started = time.time()
scores: list[float] = []
for start in range(0, len(documents), BATCH_SIZE):
scores.extend(_score(query, documents[start:start + BATCH_SIZE]))
order = sorted(range(len(documents)), key=lambda i: scores[i],
reverse=True)[:top_n]
results = []
for index in order:
item: dict[str, Any] = {
"index": index,
"relevance_score": scores[index],
}
if return_documents:
item["document"] = {"text": documents[index]}
results.append(item)
total_tokens = sum(len(TOKENIZER.encode(query, doc).ids) for doc in documents)
return {
"id": uuid.uuid4().hex,
"object": "rerank.result",
"model": MODEL_NAME,
"results": results,
"usage": {"total_tokens": total_tokens},
"meta": {"latency_ms": int((time.time() - started) * 1000)},
}
async def _handle(request: Request) -> JSONResponse:
try:
body = await request.json()
except Exception: # noqa: BLE001
return JSONResponse(
{"error": {"message": "invalid json body"}}, status_code=400
)
query = body.get("query") or ""
documents = body.get("documents") or []
top_n = int(body.get("top_n") or len(documents) or 1)
return_documents = bool(body.get("return_documents", False))
if not query or not documents:
return JSONResponse(
{"error": {"message": "query and documents are required"}},
status_code=400,
)
try:
result = _rerank(query, documents, top_n, return_documents)
except Exception as exc: # noqa: BLE001
return JSONResponse({"error": {"message": str(exc)}}, status_code=500)
return JSONResponse(result)
# 显式注册常见路径,避免依赖调用方的拼接方式
for _path in (
"/v1/services/rerank/text",
"/services/rerank/text",
"/v1/rerank",
"/rerank",
"/rank",
"/v1/rank",
):
app.post(_path)(_handle)
@app.api_route("/{full_path:path}", methods=["POST"])
async def _catch_all(full_path: str, request: Request) -> JSONResponse:
"""兜底路由。
调用方填写的 base_url 可能是 `http://host:8000` 或 `http://host:8000/v1`,
框架在其后拼接路径后可能出现 `/v1/v1/services/rerank/text` 之类的变体。
凡是路径中含 rerank、或以 /text 结尾的 POST 一律按重排请求处理;
无关路径仍返回 404,不会误吞流量。
"""
if "rerank" not in full_path and not full_path.endswith("/text"):
return JSONResponse(
{"error": {"message": f"not found: /{full_path}"}}, status_code=404
)
return await _handle(request)
@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),
}