nitdaa / agents /embed_llm.py
AI Agent
Fix OOM crash causing missing jobs by limiting PyTorch concurrency
a37260b
Raw
History Blame Contribute Delete
7.44 kB
# embed_llm.py
# General-purpose Embedding Server β€” port 8003
#
# Modes:
# GPU & HF (CPU) : BAAI/bge-small-en-v1.5 via sentence-transformers β€” dense only (~130 MB)
#
# Exposes: POST /v1/embeddings (OpenAI-compatible, dense vectors)
# GET /health
#
# Run: python agents/embed_llm.py
# β†’ http://127.0.0.1:8003
from __future__ import annotations
import os
os.environ["PYTHONWARNINGS"] = "ignore"
os.environ["TORCH_LOGS"] = "-all"
os.environ["NUMEXPR_MAX_THREADS"] = "16"
import logging
import numpy as np
from flask import Flask, request, jsonify
# ── Logging ───────────────────────────────────────────────────────────────────
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(name)s] %(levelname)s %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
log = logging.getLogger("embed_llm")
logging.getLogger("werkzeug").setLevel(logging.ERROR)
logging.getLogger("httpx").setLevel(logging.WARNING)
logging.getLogger("filelock").setLevel(logging.WARNING)
logging.getLogger("huggingface_hub").setLevel(logging.ERROR)
logging.getLogger("numexpr").setLevel(logging.ERROR)
# ── JSON serialisation helper ─────────────────────────────────────────────────
def to_python(obj):
"""Recursively convert numpy/torch objects to plain Python for jsonify."""
if isinstance(obj, dict):
return {k: to_python(v) for k, v in obj.items()}
if isinstance(obj, (list, tuple)):
return [to_python(v) for v in obj]
if isinstance(obj, np.ndarray):
return obj.tolist()
if isinstance(obj, (np.floating, np.float16, np.float32, np.float64)):
return float(obj)
if isinstance(obj, np.integer):
return int(obj)
try:
import torch
if isinstance(obj, torch.Tensor):
return obj.cpu().detach().float().item() if obj.numel() == 1 else obj.cpu().detach().float().tolist()
except ImportError:
pass
return obj
# ── Config ────────────────────────────────────────────────────────────────────
HF_MODE = True # Hardcoded to True to permanently disable GPU for HF execution
MODEL_NAME = os.getenv("EMBED_MODEL_ID", "BAAI/bge-small-en-v1.5")
MAX_LENGTH = int(os.getenv("EMBED_MAX_LENGTH", "512"))
BATCH_SIZE = int(os.getenv("EMBED_BATCH_SIZE", "12"))
HOST = os.getenv("EMBED_HOST", "127.0.0.1")
PORT = int(os.getenv("EMBED_PORT", "8003"))
log.info("━" * 60)
log.info("embed_llm starting β€” mode=%s model=%s", "HF/CPU" if HF_MODE else "GPU", MODEL_NAME)
log.info("━" * 60)
# ── Model Loading ─────────────────────────────────────────────────────────────
# GPU & HF mode β†’ sentence-transformers SentenceTransformer (lightweight, CPU-friendly)
log.info("Loading SentenceTransformer model: %s ...", MODEL_NAME)
from sentence_transformers import SentenceTransformer
_st_model = SentenceTransformer(MODEL_NAME)
# get_embedding_dimension() is the new name (sentence-transformers β‰₯ 3.x)
# Fall back to get_sentence_embedding_dimension() for older installs
_get_dim = getattr(_st_model, "get_embedding_dimension",
_st_model.get_sentence_embedding_dimension)
_embed_dim = _get_dim()
log.info("SentenceTransformer model ready β€” dim=%d", _embed_dim)
import threading
_embed_lock = threading.Lock()
def _embed_sentences(sentences: list[str]) -> np.ndarray:
"""Embed a list of sentences and return dense vectors as ndarray (N, dim)."""
with _embed_lock:
vecs = _st_model.encode(
sentences,
batch_size=BATCH_SIZE,
show_progress_bar=False,
normalize_embeddings=True,
)
return vecs if isinstance(vecs, np.ndarray) else np.array(vecs)
# ── Flask app ─────────────────────────────────────────────────────────────────
app = Flask(__name__)
@app.route("/health", methods=["GET"])
def health():
"""Liveness probe β€” returns model name, mode, and status."""
return jsonify({
"status": "ok",
"model": MODEL_NAME,
"hf_mode": HF_MODE,
"backend": "sentence-transformers",
})
# ── /v1/embeddings (OpenAI-compatible, dense vectors) ───────────────────────
@app.route("/v1/embeddings", methods=["POST"])
def embeddings():
"""
OpenAI-compatible dense-embedding endpoint.
Request body (JSON):
{ "input": str | list[str] }
Response body (JSON):
{ "object": "list", "model": str,
"data": [{"object": "embedding", "index": int, "embedding": [float, ...]}, ...] }
"""
data: dict = request.get_json(force=True) or {}
raw_input = data.get("input", "")
if not raw_input:
return jsonify({"error": "Field 'input' is required."}), 400
sentences: list[str] = raw_input if isinstance(raw_input, list) else [raw_input]
try:
dense_vecs = _embed_sentences(sentences)
except Exception as exc:
log.exception("Embedding failed")
return jsonify({"error": str(exc)}), 500
result_data = [
{
"object": "embedding",
"index": i,
"embedding": vec.tolist() if isinstance(vec, np.ndarray) else list(vec),
}
for i, vec in enumerate(dense_vecs)
]
log.info("Embedded %d sentence(s), dim=%d", len(sentences), len(result_data[0]["embedding"]))
return jsonify({"object": "list", "model": MODEL_NAME, "data": result_data})
# ── /v1/embeddings/multi (deprecated) ───────────────────────────────────────
@app.route("/v1/embeddings/multi", methods=["POST"])
def embeddings_multi():
return jsonify({
"error": "Multi-vector embeddings require bge-m3 (GPU mode). "
"Use /v1/embeddings for dense-only embeddings."
}), 501
# ── Entry point ───────────────────────────────────────────────────────────────
if __name__ == "__main__":
import signal, sys
def sigint_handler(sig, frame):
log.info("SIGINT received β€” shutting down embed_llm gracefully...")
sys.exit(0)
signal.signal(signal.SIGINT, sigint_handler)
log.info("Starting embed_llm server on %s:%d (HTTP, loopback only)", HOST, PORT)
log.info("Model: %s backend=sentence-transformers batch=%d max_len=%d",
MODEL_NAME, BATCH_SIZE, MAX_LENGTH)
# Internal microservice β€” always plain HTTP.
# SSL is handled exclusively by app.py at the browser-facing layer.
# Using HTTPS here causes "Connection reset by peer" because app.py
# connects via http:// (config.EMBED_BASE_URL) to an HTTPS server.
app.run(host=HOST, port=PORT, debug=False, threaded=True)