Spaces:
Running
Running
File size: 13,112 Bytes
ba8d0f9 d289270 e8bbc58 d289270 e8bbc58 d289270 ba8d0f9 d289270 ba8d0f9 d289270 2934687 d289270 e8bbc58 d289270 e8bbc58 ba8d0f9 d289270 ba8d0f9 d289270 e8bbc58 d289270 2934687 d289270 2934687 d289270 2934687 d289270 e8bbc58 d289270 0458600 c130de9 0458600 c130de9 e8bbc58 c130de9 0458600 e8bbc58 0458600 c6afbdf 0458600 350ad7e ba8d0f9 e8bbc58 350ad7e ba8d0f9 d289270 e8bbc58 d289270 ccc33b7 d289270 e8bbc58 d289270 | 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 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 | """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))
|