Jupyter / app /models.py
SmartDataAlex's picture
Deploy vectorization service from GitHub f104b9a67a2e52d2e138ca6a5804f9d856982c25
e8bbc58 verified
Raw History Blame Contribute Delete
13.1 kB
"""Resident E5, Qwen3, PyLate mLateOn and BGE-M3. No per-request model loading."""
import gc
import math
import os
import resource
import threading
from pathlib import Path
from dataclasses import dataclass
from app.embedding import EmbeddingEngine, EmbeddingResult, MODEL_ID as E5_ID, TooManyTokens
QWEN_ID = "Qwen/Qwen3-Embedding-0.6B"
COLBERT_ID = "lightonai/mLateOn"
BGE_M3_ID = "BAAI/bge-m3"
QWEN_MAX = 32768
COLBERT_MAX = 8192
INSTRUCTION = "Given a question, retrieve relevant passages that answer the question"
class ServiceBusy(Exception):
pass
class ModelUnavailable(Exception):
"""Requested model was intentionally not loaded at startup."""
MODEL_NAMES = ("e5", "qwen", "colbert", "bge-m3")
def enabled_models_from_env():
"""Parse the runtime load allowlist; omitted means all four models."""
raw = os.getenv("ENABLED_MODELS")
if raw is None:
enabled = set(MODEL_NAMES)
# Retain the earlier BGE-only toggle for existing deployments.
if os.getenv("BGE_M3_ENABLED", "1") != "1":
enabled.remove("bge-m3")
return frozenset(enabled)
aliases = {
"e5": "e5", E5_ID: "e5",
"qwen": "qwen", QWEN_ID: "qwen",
"colbert": "colbert", "mlateon": "colbert", COLBERT_ID: "colbert",
"bge-m3": "bge-m3", "bge_m3": "bge-m3", BGE_M3_ID: "bge-m3",
}
parts = [part.strip() for part in raw.split(",")]
if not parts or any(not part for part in parts):
raise ValueError("ENABLED_MODELS must list at least one model: "
+ ", ".join(MODEL_NAMES))
unknown = [part for part in parts if part not in aliases]
if unknown:
raise ValueError("Unknown ENABLED_MODELS entries: " + ", ".join(unknown))
mapped = [aliases[part] for part in parts]
if len(set(mapped)) != len(mapped):
raise ValueError("Duplicate models in ENABLED_MODELS")
return frozenset(mapped)
def memory_mb():
current = None
try:
with open("/proc/self/status", encoding="utf-8") as f:
for line in f:
if line.startswith("VmRSS:"):
current = round(int(line.split()[1]) / 1024, 1)
break
except OSError:
pass
peak = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
return {"rss_mb": current, "peak_rss_mb": round(peak / 1024, 1),
"budget_mb": int(os.getenv("MEMORY_BUDGET_MB", "15360"))}
def validate_vector(vector, dimensions):
if len(vector) != dimensions or any(not math.isfinite(v) for v in vector):
raise RuntimeError("Invalid embedding vector")
class QwenEngine:
dimensions = 1024
max_tokens = QWEN_MAX
def __init__(self, tokenizer, model, torch):
self.tokenizer, self.model, self.torch = tokenizer, model, torch
@classmethod
def load(cls):
import torch
from transformers import AutoModel, AutoTokenizer
path = os.getenv("QWEN_MODEL_DIR", "/opt/models/qwen3-embedding-0.6b")
tokenizer = AutoTokenizer.from_pretrained(path, padding_side="left",
local_files_only=True)
model = AutoModel.from_pretrained(path, torch_dtype=torch.float32,
attn_implementation="sdpa",
local_files_only=True).to("cpu").eval()
if int(model.config.hidden_size) != 1024:
raise RuntimeError("Incorrect Qwen output size")
return cls(tokenizer, model, torch)
def encode(self, text, kind):
inp = (f"Instruct: {INSTRUCTION}\nQuery:{text}"
if kind == "query" else text)
tokens = self.tokenizer(inp, add_special_tokens=True, truncation=False,
return_tensors="pt")
count = int(tokens["attention_mask"].sum().item())
if count > self.max_tokens:
raise TooManyTokens(count, self.max_tokens)
with self.torch.inference_mode():
last = self.model(**tokens).last_hidden_state[:, -1, :]
normalized = self.torch.nn.functional.normalize(last, p=2, dim=1)
values = normalized[0].float().cpu().tolist()
validate_vector(values, self.dimensions)
return EmbeddingResult(tokens_count=count, embedding=values)
@dataclass(frozen=True)
class ColbertResult:
tokens_count: int
token_embeddings: list[list[float]]
class ColbertEngine:
dimensions = 128
max_tokens = COLBERT_MAX
def __init__(self, model):
self.model = model
@classmethod
def load(cls):
from pylate import models
model = models.ColBERT(
model_name_or_path=os.getenv("COLBERT_MODEL_DIR", "/opt/models/mlateon"),
device="cpu", query_length=32, document_length=COLBERT_MAX,
)
model.eval()
model.document_length = COLBERT_MAX
return cls(model)
def encode(self, text, kind):
is_query = kind == "query"
prefix = self.model.query_prefix if is_query else self.model.document_prefix
tokens = self.model.tokenizer(f"{prefix}{text}", add_special_tokens=True,
truncation=False)
count = len(tokens["input_ids"])
if count > self.max_tokens:
raise TooManyTokens(count, self.max_tokens)
if is_query:
# PyLate pads queries to query_length. Never pad short queries to 8192.
self.model.query_length = max(32, count)
output = self.model.encode([text], is_query=is_query, batch_size=1,
show_progress_bar=False)
if len(output) != 1:
raise RuntimeError("Unexpected PyLate batch size")
matrix = output[0]
if hasattr(matrix, "detach"):
matrix = matrix.detach().float().cpu()
if hasattr(matrix, "tolist"):
matrix = matrix.tolist()
vectors = [[float(x) for x in row] for row in matrix]
if not vectors or len(vectors) > COLBERT_MAX:
raise RuntimeError("Unexpected PyLate output length")
for row in vectors:
validate_vector(row, self.dimensions)
return ColbertResult(tokens_count=len(vectors), token_embeddings=vectors)
class ModelRegistry:
def __init__(self, e5, qwen, colbert, startup_memory=None, bge_m3=None):
self.e5, self.qwen, self.colbert = e5, qwen, colbert
self.bge_m3 = bge_m3
self.startup_memory = startup_memory or {}
# Only one CPU inference may execute at a time. Allow a bounded
# number of other HTTP calls to wait instead of returning 429 instantly.
# Dify's embeddings provider has a 60-second HTTP timeout.
pending = max(0, int(os.getenv("INFERENCE_MAX_WAITERS", "16")))
self._waiting_slots = threading.BoundedSemaphore(pending + 1)
self._gate = threading.BoundedSemaphore(1)
@classmethod
def load(cls):
import torch
torch.set_num_threads(max(1, min(4, int(os.getenv("CPU_THREADS", "2")))))
enabled = enabled_models_from_env()
readings = {}
e5 = qwen = colbert = bge_m3 = None
if "e5" in enabled:
e5 = EmbeddingEngine.load()
readings["e5"] = memory_mb()
if "qwen" in enabled:
qwen = QwenEngine.load()
readings["qwen"] = memory_mb()
if "colbert" in enabled:
colbert = ColbertEngine.load()
readings["colbert"] = memory_mb()
if "bge-m3" in enabled:
from app.bge_m3 import BgeM3Engine
bge_m3 = BgeM3Engine.load()
readings["bge_m3"] = memory_mb()
gc.collect()
ram = memory_mb()
if ram["rss_mb"] is not None and ram["rss_mb"] > ram["budget_mb"]:
raise MemoryError("All resident models exceed RAM budget")
return cls(e5, qwen, colbert, readings, bge_m3=bge_m3)
def loaded_models(self):
return [name for name, engine in (
("e5", self.e5), ("qwen", self.qwen),
("colbert", self.colbert), ("bge-m3", self.bge_m3),
) if engine is not None]
def _run(self, callback):
# Keep inference serialized to protect CPU RAM, but give parallel
# clients time to queue. The queue is bounded to avoid thread exhaustion.
if not self._waiting_slots.acquire(blocking=False):
raise ServiceBusy("Inference queue is full; retry later")
acquired = False
try:
timeout = max(0.0, float(os.getenv("INFERENCE_WAIT_SECONDS", "40")))
acquired = self._gate.acquire(timeout=timeout)
if not acquired:
raise ServiceBusy("Inference queue wait timed out; retry later")
return callback()
finally:
if acquired:
self._gate.release()
self._waiting_slots.release()
def embed(self, text, kind, selected):
engine = self.qwen if selected in ("qwen", QWEN_ID) else self.e5
if engine is None:
raise ModelUnavailable(f"Model {selected or 'e5'} is not enabled")
result = self._run(lambda: engine.encode(text, kind))
return (QWEN_ID if engine is self.qwen else E5_ID), engine.dimensions, result
def openai_encode(self, texts, canonical_model):
"""OpenAI-compatible output is always dense only."""
from app.batching import batch_encode
from app.bge_m3 import BGE_M3_ID
if canonical_model == BGE_M3_ID:
if self.bge_m3 is None:
raise ModelUnavailable("Model bge-m3 is not enabled")
def encode_bge_dense():
engine = self.bge_m3
counts = [
len(engine.model.tokenizer(text, add_special_tokens=True,
truncation=False)["input_ids"])
for text in texts
]
for index, count in enumerate(counts):
if count > engine.max_tokens:
raise TooManyTokens(count, engine.max_tokens,
input_index=index)
if sum(counts) > 65536:
raise ValueError("Batch exceeds 65536 total input tokens")
results = []
group, group_tokens = [], 0
# Dify may send 16+ texts; internal native BGE max is 8.
for text, count in zip(texts, counts):
if group and (len(group) == 8 or group_tokens + count > 8192):
results.extend(engine.encode(
group, return_sparse=False, return_colbert=False))
group, group_tokens = [], 0
group.append(text)
group_tokens += count
if group:
results.extend(engine.encode(
group, return_sparse=False, return_colbert=False))
return [EmbeddingResult(
tokens_count=item.tokens_count, embedding=item.dense)
for item in results]
return self._run(encode_bge_dense)
if canonical_model == E5_ID:
engine, is_qwen = self.e5, False
elif canonical_model == QWEN_ID:
engine, is_qwen = self.qwen, True
else:
raise ValueError("OpenAI embeddings only support dense E5 and Qwen models")
if engine is None:
raise ModelUnavailable(f"Model {canonical_model} is not enabled")
return self._run(lambda: batch_encode(
engine, texts, "passage", is_qwen=is_qwen, raw=True,
truncate_long=(canonical_model == E5_ID),
))
def bge_m3_encode(self, texts, return_colbert=False):
if self.bge_m3 is None:
raise ModelUnavailable("Model bge-m3 is not enabled")
return self._run(lambda: self.bge_m3.encode(
texts, return_colbert=return_colbert,
))
def colbert_encode(self, text, kind):
if self.colbert is None:
raise ModelUnavailable("Model colbert is not enabled")
return self._run(lambda: self.colbert.encode(text, kind))
def voyage_encode(self, texts, kind, canonical_model):
"""Atomic inference gate protects all microbatches in one HTTP request."""
from app.batching import batch_encode
if canonical_model == E5_ID:
# Same 512-token safety fallback as the OpenAI E5 endpoint.
# _dense_batch adds query:/passage: before truncating the tail.
engine, mode = self.e5, {"truncate_long": True}
elif canonical_model == QWEN_ID:
engine, mode = self.qwen, {"is_qwen": True}
elif canonical_model == COLBERT_ID:
engine, mode = self.colbert, {"is_colbert": True}
else:
raise ValueError("Unknown model")
if engine is None:
raise ModelUnavailable(f"Model {canonical_model} is not enabled")
return self._run(lambda: batch_encode(engine, texts, kind, **mode))