File size: 5,502 Bytes
071760e a1c0982 071760e a1c0982 071760e 138e250 a1c0982 071760e ffe2183 071760e 138e250 071760e 138e250 937d2ca 071760e 138e250 071760e 138e250 071760e 138e250 a1c0982 071760e a1c0982 138e250 071760e 937d2ca 071760e 937d2ca 071760e 138e250 a1c0982 071760e 138e250 a1c0982 071760e 138e250 937d2ca 071760e 937d2ca 071760e 138e250 071760e 138e250 937d2ca 071760e 937d2ca 071760e 937d2ca 138e250 071760e a1c0982 071760e | 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 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 | import asyncio
import hmac
import os
from contextlib import asynccontextmanager
from typing import Literal
import torch
import torch.nn.functional as F
from fastapi import FastAPI, Header, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel, Field
from sentence_transformers import SentenceTransformer
from transformers import AutoModel, AutoTokenizer
MODEL_PATH = os.getenv("MODEL_PATH", "/app/model")
DEVICE = os.getenv("DEVICE", "cpu")
MAX_BATCH_SIZE = int(os.getenv("MAX_BATCH_SIZE", "64"))
MAX_LENGTH = int(os.getenv("MAX_LENGTH", "512"))
EXPECTED_DIMENSION = 384
API_TOKEN = os.getenv("EMBEDDING_API_TOKEN")
ALLOWED_ORIGINS = [
origin.strip()
for origin in os.getenv("ALLOWED_ORIGINS", "").split(",")
if origin.strip()
]
st_model: SentenceTransformer | None = None
hf_model = None
hf_tokenizer = None
encode_lock: asyncio.Lock | None = None
def _load_model() -> None:
global st_model, hf_model, hf_tokenizer
try:
st_model = SentenceTransformer(MODEL_PATH, device=DEVICE)
st_model.max_seq_length = MAX_LENGTH
except Exception:
hf_tokenizer = AutoTokenizer.from_pretrained(MODEL_PATH)
hf_model = AutoModel.from_pretrained(MODEL_PATH).to(DEVICE)
hf_model.eval()
@asynccontextmanager
async def lifespan(_: FastAPI):
global encode_lock
await asyncio.to_thread(_load_model)
if st_model is None and hf_model is None:
raise RuntimeError("E5 model failed to load")
encode_lock = asyncio.Lock()
yield
app = FastAPI(title="Studify E5 Embeddings", version="2.0.0", lifespan=lifespan)
if ALLOWED_ORIGINS:
app.add_middleware(
CORSMiddleware,
allow_origins=ALLOWED_ORIGINS,
allow_methods=["GET", "POST"],
allow_headers=["Content-Type", "Authorization"],
)
class EmbeddingRequest(BaseModel):
input: str = Field(min_length=1)
task: Literal["query", "passage"] = "passage"
class BatchRequest(BaseModel):
inputs: list[str] = Field(min_length=1)
task: Literal["query", "passage"] = "passage"
def _authorize(authorization: str | None) -> None:
if not API_TOKEN:
raise HTTPException(503, "EMBEDDING_API_TOKEN is not configured")
supplied = (
authorization[7:]
if authorization and authorization.startswith("Bearer ")
else ""
)
if not hmac.compare_digest(supplied, API_TOKEN):
raise HTTPException(401, "unauthorized")
def _prefix(text: str, task: Literal["query", "passage"]) -> str:
return f"{task}: {text.strip()}"
def _mean_pool(last_hidden_state: torch.Tensor, attention_mask: torch.Tensor):
mask = attention_mask.unsqueeze(-1).expand(last_hidden_state.size()).float()
summed = torch.sum(last_hidden_state * mask, dim=1)
counts = torch.clamp(mask.sum(dim=1), min=1e-9)
return summed / counts
def _encode(texts: list[str]) -> list[list[float]]:
if st_model is not None:
return st_model.encode(
texts,
batch_size=min(32, len(texts)),
normalize_embeddings=True,
convert_to_numpy=True,
show_progress_bar=False,
).tolist()
if hf_model is None or hf_tokenizer is None:
raise RuntimeError("model is not loaded")
inputs = hf_tokenizer(
texts,
padding=True,
truncation=True,
max_length=MAX_LENGTH,
return_tensors="pt",
).to(DEVICE)
with torch.inference_mode():
outputs = hf_model(**inputs)
pooled = _mean_pool(outputs.last_hidden_state, inputs["attention_mask"])
normalized = F.normalize(pooled, p=2, dim=1)
return normalized.cpu().tolist()
async def _encode_safely(texts: list[str]) -> list[list[float]]:
if len(texts) > MAX_BATCH_SIZE:
raise HTTPException(413, f"batch exceeds MAX_BATCH_SIZE={MAX_BATCH_SIZE}")
if any(not text.strip() for text in texts):
raise HTTPException(422, "inputs cannot contain empty strings")
assert encode_lock is not None
async with encode_lock:
try:
vectors = await asyncio.to_thread(_encode, texts)
if any(len(vector) != EXPECTED_DIMENSION for vector in vectors):
raise RuntimeError(
f"model dimension mismatch; expected {EXPECTED_DIMENSION}"
)
return vectors
except HTTPException:
raise
except Exception as error:
raise HTTPException(500, f"embedding failed: {error}") from error
@app.get("/")
@app.get("/healthz")
async def health():
if st_model is None and hf_model is None:
raise HTTPException(503, "model not loaded")
return {
"status": "ok",
"device": DEVICE,
"model_path": MODEL_PATH,
"dimension": EXPECTED_DIMENSION,
}
@app.post("/embed")
async def embed(
request: EmbeddingRequest,
authorization: str | None = Header(default=None),
):
_authorize(authorization)
vectors = await _encode_safely([_prefix(request.input, request.task)])
return {"embedding": vectors[0], "dim": len(vectors[0])}
@app.post("/embed/batch")
async def embed_batch(
request: BatchRequest,
authorization: str | None = Header(default=None),
):
_authorize(authorization)
vectors = await _encode_safely(
[_prefix(text, request.task) for text in request.inputs]
)
return {
"embeddings": vectors,
"count": len(vectors),
"dim": len(vectors[0]),
}
|