RICS / backend /core /embeddings.py
StormShadow308's picture
Add demo documentation and Docker setup for v2 report generation system
aad7814
Raw
History Blame Contribute Delete
4.57 kB
"""Embedding abstraction for the v2 backend.
Default backend is a free, local sentence-transformers model (MiniLM). Setting
``embedding_provider='openai'`` switches to OpenAI embeddings. The active
embedder exposes ``embed_dim`` so the FAISS store can size its index, and a
deterministic fake backend is used when neither dependency is available (tests).
"""
from __future__ import annotations
import hashlib
import logging
import struct
from backend.config import settings
logger = logging.getLogger(__name__)
class Embedder:
"""Minimal embedding interface used by the RAG store."""
embed_dim: int
def embed_documents(self, texts: list[str]) -> list[list[float]]: # pragma: no cover - interface
raise NotImplementedError
def embed_query(self, text: str) -> list[float]: # pragma: no cover - interface
raise NotImplementedError
class LocalEmbedder(Embedder):
"""sentence-transformers backend (default)."""
def __init__(self, model_name: str) -> None:
from sentence_transformers import SentenceTransformer
self._model = SentenceTransformer(model_name)
self.embed_dim = int(self._model.get_sentence_embedding_dimension())
logger.info("LocalEmbedder loaded %s (dim=%d)", model_name, self.embed_dim)
def embed_documents(self, texts: list[str]) -> list[list[float]]:
vecs = self._model.encode(
list(texts), normalize_embeddings=True, convert_to_numpy=True
)
return [v.tolist() for v in vecs]
def embed_query(self, text: str) -> list[float]:
return self.embed_documents([text])[0]
class OpenAIEmbedder(Embedder):
"""OpenAI embeddings backend."""
_DIMS = {
"text-embedding-3-small": 1536,
"text-embedding-3-large": 3072,
"text-embedding-ada-002": 1536,
}
def __init__(self, model_name: str, api_key: str) -> None:
from openai import OpenAI
self._client = OpenAI(
api_key=api_key,
timeout=float(settings.openai_request_timeout_seconds),
)
self._model = model_name
self.embed_dim = self._DIMS.get(model_name, 1536)
logger.info("OpenAIEmbedder using %s (dim=%d)", model_name, self.embed_dim)
def embed_documents(self, texts: list[str]) -> list[list[float]]:
from backend.llm.openai_client import pipeline_timeout
resp = self._client.embeddings.create(
model=self._model,
input=list(texts),
timeout=pipeline_timeout(),
)
return [d.embedding for d in resp.data]
def embed_query(self, text: str) -> list[float]:
return self.embed_documents([text])[0]
class FakeEmbedder(Embedder):
"""Deterministic hash-based embedder for offline tests."""
def __init__(self, dim: int = 384) -> None:
self.embed_dim = dim
def _vec(self, text: str) -> list[float]:
out: list[float] = []
seed = (text or "").encode("utf-8")
counter = 0
while len(out) < self.embed_dim:
h = hashlib.sha256(seed + struct.pack(">I", counter)).digest()
for i in range(0, len(h), 4):
if len(out) >= self.embed_dim:
break
(val,) = struct.unpack(">I", h[i : i + 4])
out.append((val / 0xFFFFFFFF) * 2.0 - 1.0)
counter += 1
norm = sum(x * x for x in out) ** 0.5 or 1.0
return [x / norm for x in out]
def embed_documents(self, texts: list[str]) -> list[list[float]]:
return [self._vec(t) for t in texts]
def embed_query(self, text: str) -> list[float]:
return self._vec(text)
_instance: Embedder | None = None
def get_embedder() -> Embedder:
"""Return the cached embedder singleton selected by configuration."""
global _instance
if _instance is not None:
return _instance
provider = (settings.embedding_provider or "local").lower()
try:
if provider == "openai" and settings.openai_api_key:
_instance = OpenAIEmbedder(settings.openai_embedding_model, settings.openai_api_key)
else:
_instance = LocalEmbedder(settings.local_embedding_model)
except Exception as exc: # noqa: BLE001 - dependency/model load failures → fake
logger.warning("Embedder init failed (%s); using FakeEmbedder (TEST ONLY).", exc)
_instance = FakeEmbedder()
return _instance
def reset_embedder() -> None:
"""Reset the cached embedder (tests / config reloads)."""
global _instance
_instance = None