File size: 3,579 Bytes
3dc597f
 
 
d23fe11
 
3dc597f
d23fe11
3dc597f
bbad332
3dc597f
d23fe11
 
bbad332
d23fe11
3dc597f
bbad332
 
c59ccf4
 
 
3dc597f
d23fe11
bbad332
d23fe11
3dc597f
 
 
 
d23fe11
bbad332
d23fe11
c59ccf4
 
 
 
 
 
 
d23fe11
c59ccf4
3dc597f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d23fe11
3dc597f
 
bbad332
 
 
 
 
 
d23fe11
bbad332
de7bd61
 
 
 
 
d23fe11
 
 
 
 
bbad332
 
 
 
 
3dc597f
 
 
 
d23fe11
3dc597f
 
 
 
bbad332
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
import os
from contextlib import asynccontextmanager

import numpy as np
import onnxruntime as ort
from fastapi import FastAPI, Header, HTTPException
from huggingface_hub import hf_hub_download
from pydantic import BaseModel
from transformers import AutoTokenizer

# int8 ONNX bge-m3, dense-only. Run raw via onnxruntime — no torch/optimum, so
# no export-path version conflicts. ~3x faster + ~half RAM vs fp32.
MODEL_NAME = os.getenv("EMBED_MODEL", "libryo-ai/BAAI-bge-m3-int8")
ONNX_FILE = os.getenv("EMBED_ONNX_FILE", "model.onnx")
EMBED_TOKEN = os.getenv("EMBED_TOKEN")  # optional shared secret
MAX_TOKENS = int(os.getenv("EMBED_MAX_TOKENS", "512"))
BATCH = int(os.getenv("EMBED_BATCH", "32"))
# HF free CPU exposes more cores than it gives you; let onnxruntime's default
# (0 = pick all) be overridable. Tune via env if 2 isn't fastest.
THREADS = int(os.getenv("EMBED_THREADS", "0"))

_session: ort.InferenceSession | None = None
_tokenizer = None
_input_names: set[str] = set()


@asynccontextmanager
async def lifespan(_: FastAPI):
    global _session, _tokenizer, _input_names
    _tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)
    path = hf_hub_download(MODEL_NAME, ONNX_FILE)
    opts = ort.SessionOptions()
    opts.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
    if THREADS:
        opts.intra_op_num_threads = THREADS
    _session = ort.InferenceSession(
        path, sess_options=opts, providers=["CPUExecutionProvider"]
    )
    _input_names = {i.name for i in _session.get_inputs()}
    _encode(["warmup"])  # pay first-run graph optimization now, not on a user request
    yield


app = FastAPI(lifespan=lifespan)


class EmbedRequest(BaseModel):
    texts: list[str]


class EmbedResponse(BaseModel):
    embeddings: list[list[float]]
    dim: int
    model: str


@app.get("/")
def health():
    return {"status": "ok", "model": MODEL_NAME, "ready": _session is not None}


def _encode(texts: list[str]) -> list[list[float]]:
    out: list[list[float]] = []
    for i in range(0, len(texts), BATCH):
        chunk = texts[i : i + BATCH]
        enc = _tokenizer(
            chunk, padding=True, truncation=True,
            max_length=MAX_TOKENS, return_tensors="np",
        )
        # The ONNX graph requires token_type_ids but the xlm-roberta tokenizer
        # doesn't emit them (bge-m3 ignores them) → feed zeros for any required
        # input the tokenizer didn't produce.
        ids = enc["input_ids"]
        feed = {n: enc[n] if n in enc else np.zeros_like(ids) for n in _input_names}
        hidden = _session.run(None, feed)[0]  # (B, T, 1024) last_hidden_state
        # dense embedding = CLS token (position 0), then L2-normalize so cosine == dot.
        cls = hidden[:, 0]
        cls = cls / np.clip(np.linalg.norm(cls, axis=1, keepdims=True), 1e-12, None)
        out.extend(cls.astype(np.float32).tolist())
    return out


# sync `def` → FastAPI runs it in a threadpool, so the blocking ONNX inference
# does not stall the event loop.
@app.post("/embed", response_model=EmbedResponse)
def embed(req: EmbedRequest, authorization: str | None = Header(default=None)):
    if EMBED_TOKEN and authorization != f"Bearer {EMBED_TOKEN}":
        raise HTTPException(status_code=401, detail="unauthorized")
    if _session is None:
        raise HTTPException(status_code=503, detail="model loading")
    if not req.texts:
        raise HTTPException(status_code=400, detail="texts required")

    vecs = _encode(req.texts)
    return EmbedResponse(embeddings=vecs, dim=len(vecs[0]), model=MODEL_NAME)