Ctrl K
ComunidadGuías…

moha_mind/agent/embeddings.py

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""Pluggable embedding backends for MohaMind semantic memory.

Three backends are supported:
- none   -> embeddings disabled. Factory returns None and callers gracefully
            skip semantic features (FTS5 + substring search still work).
- openai -> uses the existing OpenAI client to call text-embedding-3-small
            (or another configured model).
- local  -> uses sentence-transformers locally. Only imported on demand so
            the optional dependency never breaks the default install.

The `Embedder` protocol keeps the semantic index decoupled from the backend.
"""

from __future__ import annotations

import asyncio
from typing import Optional, Protocol

from moha_mind.config import settings
from moha_mind.utils.logging_config import log

DEFAULT_OPENAI_MODEL = "text-embedding-3-small"
DEFAULT_LOCAL_MODEL = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"


class Embedder(Protocol):
    provider: str
    model: str
    dim: int

    def encode(self, texts: list[str]) -> list[list[float]]: ...


class OpenAIEmbedder:
    def __init__(self, api_key: str, model: str = DEFAULT_OPENAI_MODEL, base_url: Optional[str] = None):
        from openai import OpenAI  # imported lazily — avoids network at import time

        self.provider = "openai"
        self.model = model or DEFAULT_OPENAI_MODEL
        self._client = OpenAI(api_key=api_key, base_url=base_url)
        # 1536 for text-embedding-3-small, 3072 for -3-large, we just probe on first use.
        self._dim: Optional[int] = None

    @property
    def dim(self) -> int:
        if self._dim is None:
            self.encode(["probe"])
        return self._dim or 0

    def encode(self, texts: list[str]) -> list[list[float]]:
        if not texts:
            return []
        clean = [t.strip() for t in texts if t and t.strip()]
        if not clean:
            return [[] for _ in texts]
        try:
            resp = self._client.embeddings.create(model=self.model, input=clean)
        except Exception as exc:
            log.warning(f"OpenAI embedding call failed: {exc}")
            return [[] for _ in texts]
        vectors = [item.embedding for item in resp.data]
        if vectors and self._dim is None:
            self._dim = len(vectors[0])
        return vectors


class LocalEmbedder:
    def __init__(self, model: str = DEFAULT_LOCAL_MODEL):
        try:
            from sentence_transformers import SentenceTransformer  # type: ignore
        except ImportError as exc:  # pragma: no cover - depends on optional dep
            raise RuntimeError(
                "Local embeddings require 'sentence-transformers'. "
                "Install with: uv add 'sentence-transformers'"
            ) from exc

        self.provider = "local"
        self.model = model or DEFAULT_LOCAL_MODEL
        self._model = SentenceTransformer(self.model)
        self._dim = int(self._model.get_sentence_embedding_dimension() or 0)

    @property
    def dim(self) -> int:
        return self._dim

    def encode(self, texts: list[str]) -> list[list[float]]:
        if not texts:
            return []
        clean = [t.strip() or " " for t in texts]
        vectors = self._model.encode(clean, convert_to_numpy=False, show_progress_bar=False)
        return [list(map(float, v)) for v in vectors]


def build_embedder() -> Optional[Embedder]:
    """Factory that reads settings and returns an Embedder, or None if disabled."""
    backend = (getattr(settings, "embedding_backend", "none") or "none").lower()
    if backend == "none":
        return None

    if backend == "openai":
        api_key = getattr(settings, "openai_api_key", "") or ""
        if not api_key:
            log.warning("Embedding backend=openai but OPENAI_API_KEY is not set. Disabling semantic memory.")
            return None
        model = getattr(settings, "embedding_model", "") or DEFAULT_OPENAI_MODEL
        base_url = getattr(settings, "openai_base_url", "") or None
        try:
            return OpenAIEmbedder(api_key=api_key, model=model, base_url=base_url)
        except Exception as exc:
            log.warning(f"OpenAI embedder init failed: {exc}")
            return None

    if backend == "local":
        model = getattr(settings, "embedding_model", "") or DEFAULT_LOCAL_MODEL
        try:
            return LocalEmbedder(model=model)
        except Exception as exc:
            log.warning(f"Local embedder init failed: {exc}")
            return None

    log.warning(f"Unknown embedding_backend={backend!r}. Disabling semantic memory.")
    return None


async def encode_async(embedder: Embedder, texts: list[str]) -> list[list[float]]:
    """Run a potentially-blocking encode in a thread so it doesn't block the event loop."""
    return await asyncio.to_thread(embedder.encode, texts)