Files
pi-qmem/gateway/embed.py
T
enne2 fcd6b1670e feat(gateway): catena di fallback per gli embedding + retry transiente su Qdrant
- embed.py: EMBED_CHAIN (JSON per-nodo {name,url,api,key,timeout_ms}, api
  llamacpp|ollama), cooldown 60s sui nodi falliti, validazione dimensione
  EMBED_DIM, compatibilità legacy quando la catena è vuota
- state.py: ResilientQdrant — proxy che ritenta i metodi del client Qdrant
  su httpx.TransportError (store/search/transienti), errori applicativi
  esenti; contatore qdrant_retries in metriche
- metrics: qmem_embed_calls_total + durata per backend
- /v1/version espone embed_nodes; versione 2.10.0
- test: 11 nuovi (chain, cooldown, dim mismatch, legacy, retry transiente) — 58 pass
2026-09-08 12:19:16 +02:00

222 lines
7.5 KiB
Python

"""Embedding denso (Ollama/llama.cpp) e sparse BM25, con catena di fallback resiliente.
Catena da EMBED_CHAIN (JSON, stesso formato di RERANK_CHAIN + campo "api"):
il primo nodo raggiungibile vince, i nodi falliti entrano in cooldown. Se
EMBED_CHAIN è vuota si usa il comportamento legacy (endpoint singolo da
EMBED_API/EMBED_URL/EMBED_API_KEY). L'ultimo errore viene rilanciato al client
come gli endpoint precedenti: nessuna degradazione silenziosa della scrittura.
"""
from __future__ import annotations
import json
import time
from dataclasses import dataclass
from typing import Any, Optional
import httpx
from qdrant_client.http import models as qm
import metrics
from config import (
EMBED_API,
EMBED_API_KEY,
EMBED_CHAIN,
EMBED_DIM,
EMBED_MODEL,
EMBED_RETRY_COOLDOWN_S,
EMBED_TIMEOUT_MS,
EMBED_URL,
SPARSE_VECTOR_NAME,
log,
)
try:
from fastembed import SparseTextEmbedding
_sparse_model: Optional[Any] = None
SPARSE_AVAILABLE = True
except Exception: # noqa: BLE001
_sparse_model = None
SPARSE_AVAILABLE = False
log.warning("fastembed non disponibile: hybrid retrieval disattivato")
_http: Optional[httpx.AsyncClient] = None
@dataclass(frozen=True)
class EmbedNode:
"""Un endpoint embedding nella catena di fallback."""
name: str
url: str
api: str # "llamacpp" (/v1/embeddings) | "ollama" (/api/embed)
key: str
timeout_ms: int
def parse_chain(raw: str, default_api: str, default_url: str, default_key: str) -> list[EmbedNode]:
"""Parsa EMBED_CHAIN (JSON); vuota o invalida → endpoint legacy singolo."""
nodes: list[EmbedNode] = []
if raw:
try:
entries = json.loads(raw)
for entry in entries if isinstance(entries, list) else []:
if not isinstance(entry, dict) or not entry.get("url"):
continue
url = str(entry["url"]).rstrip("/")
api = str(entry.get("api") or "llamacpp")
if api not in ("llamacpp", "ollama") or not url.startswith(("http://", "https://")):
continue
nodes.append(
EmbedNode(
name=str(entry.get("name") or url),
url=url,
api=api,
key=str(entry.get("key") or ""),
timeout_ms=int(entry.get("timeout_ms", EMBED_TIMEOUT_MS)),
)
)
except (json.JSONDecodeError, TypeError, ValueError):
log.error("EMBED_CHAIN non è JSON valido: uso l'endpoint legacy")
if not nodes and default_url:
# Compatibilità legacy: endpoint singolo dagli env EMBED_*
nodes = [EmbedNode(name="embed", url=default_url.rstrip("/"), api=default_api, key=default_key, timeout_ms=EMBED_TIMEOUT_MS)]
return nodes
_chain: Optional[list[EmbedNode]] = None
_down_until: dict[str, float] = {} # url → monotonic deadline del cooldown
_http: Optional[httpx.AsyncClient] = None
def _get_chain() -> list[EmbedNode]:
global _chain
if _chain is None:
_chain = parse_chain(EMBED_CHAIN, EMBED_API, EMBED_URL, EMBED_API_KEY)
return _chain
def reset_chain_cache() -> None:
"""Forza il re-parse della catena (usato dai test)."""
global _chain
_chain = None
_down_until.clear()
def get_http() -> httpx.AsyncClient:
global _http
if _http is None:
_http = httpx.AsyncClient(timeout=30)
return _http
async def close_http() -> None:
global _http
if _http is not None:
await _http.aclose()
_http = None
def chain_nodes() -> list[EmbedNode]:
return _get_chain()
async def embed(text: str) -> list[float]:
"""Embedding con catena di fallback: ritorna il vettore o rilancia dopo l'ultimo fallimento."""
chain = _get_chain()
if not chain:
raise RuntimeError("nessun endpoint embedding configurato")
now = time.monotonic()
live = [n for n in chain if _down_until.get(n.url, 0) <= now]
if not live:
# tutti in cooldown: ritenta comunque il primo (meglio di un fallimento immediato)
live = [chain[0]]
payload = {"model": EMBED_MODEL, "input": text}
started = time.monotonic()
last_exc: Optional[Exception] = None
for node in live:
headers = {"Content-Type": "application/json"}
if node.key:
headers["Authorization"] = f"Bearer {node.key}"
path = "/v1/embeddings" if node.api == "llamacpp" else "/api/embed"
try:
t0 = time.monotonic()
response = await get_http().post(
f"{node.url}{path}",
json=payload,
headers=headers,
timeout=httpx.Timeout(node.timeout_ms / 1000.0),
)
response.raise_for_status()
data = response.json()
vector = data["data"][0]["embedding"] if node.api == "llamacpp" else data["embeddings"][0]
if len(vector) != EMBED_DIM:
raise ValueError(f"dimensione vettore {len(vector)} != EMBED_DIM {EMBED_DIM}")
took = int((time.monotonic() - started) * 1000)
metrics.record_embed(node.name, True, took)
return vector
except (httpx.HTTPError, ValueError, KeyError, IndexError, TypeError) as exc:
took = int((time.monotonic() - t0) * 1000)
_down_until[node.url] = time.monotonic() + EMBED_RETRY_COOLDOWN_S
last_exc = exc
metrics.record_embed(node.name, False, took)
log.warning(
"embed: nodo '%s' fallito dopo %dms (%s: %s) → cooldown %ds",
node.name,
took,
exc.__class__.__name__,
exc,
EMBED_RETRY_COOLDOWN_S,
)
raise RuntimeError(f"tutti i nodi embedding falliti ({len(live)} tentativi)") from last_exc
def get_sparse_model():
global _sparse_model
if _sparse_model is None and SPARSE_AVAILABLE:
_sparse_model = SparseTextEmbedding(model_name="Qdrant/bm25")
return _sparse_model
def sparse_encode(text: str) -> Optional[qm.SparseVector]:
model = get_sparse_model()
if model is None:
return None
emb = next(model.embed(text))
return qm.SparseVector(indices=emb.indices.tolist(), values=emb.values.tolist())
def backfill_sparse(qdrant: Any, collection: str) -> None:
if not SPARSE_AVAILABLE:
return
offset: Any = None
updated = 0
while True:
points, next_offset = qdrant.scroll(
collection_name=collection,
limit=100,
with_payload=["text"],
with_vectors=True,
offset=offset,
)
batch: list[qm.PointStruct] = []
for p in points:
vecs = p.vector or {}
if SPARSE_VECTOR_NAME in vecs:
continue
text = (p.payload or {}).get("text", "")
if not text:
continue
sparse = sparse_encode(text)
if sparse is None:
continue
batch.append(qm.PointStruct(id=p.id, vector={SPARSE_VECTOR_NAME: sparse}))
if batch:
# update_vectors: aggiorna SOLO il vettore sparso, preservando payload e vettore denso
# (upsert parziale sostituirebbe l'intero punto — incidente 2026-08-16)
qdrant.update_vectors(collection_name=collection, points=batch)
updated += len(batch)
if not next_offset:
break
offset = next_offset
if updated:
log.info("backfill sparse: %d record aggiornati", updated)