Source code for redisvl.utils.vectorize.googlegenai

import os
from typing import TYPE_CHECKING, Any

from pydantic import ConfigDict
from tenacity import (
    retry,
    retry_if_not_exception_type,
    stop_after_attempt,
    wait_random_exponential,
)

from redisvl.utils.vectorize.base import BaseVectorizer

if TYPE_CHECKING:
    from redisvl.extensions.cache.embeddings.embeddings import EmbeddingsCache


[docs] class GoogleGenAIVectorizer(BaseVectorizer): """The GoogleGenAIVectorizer creates embeddings with Google's `google-genai` SDK, the supported replacement for the deprecated Vertex AI model-garden SDK used by :class:`VertexAIVectorizer`. One client, two backends. `google-genai` reaches both of Google's embedding backends; this vectorizer auto-selects one from your config/environment (or an explicit override): - **Vertex AI / Gemini Enterprise** (GCP project auth) — for existing Vertex users. - **Gemini Developer API** (a single API key) — the simplest way to get started. Credentials are resolved in this order (explicit beats ambient): 1. Explicit override — ``api_config={"backend": "vertex" | "gemini"}``. 2. Explicit creds in ``api_config`` — ``api_key`` selects Gemini; ``project_id`` (+ ``location``) selects Vertex. 3. Environment — a project (``GOOGLE_CLOUD_PROJECT`` | ``GCP_PROJECT_ID`` with ``GOOGLE_CLOUD_LOCATION`` | ``GCP_LOCATION`` and Application Default Credentials via ``GOOGLE_APPLICATION_CREDENTIALS``) selects Vertex; otherwise ``GEMINI_API_KEY`` | ``GOOGLE_API_KEY`` selects Gemini. If both a project and a key are present, Vertex wins. Install the client with ``pip install redisvl[google-genai]``. .. note:: The default model ``gemini-embedding-001`` returns **3072-dimensional** vectors (≈4× the width of the legacy ``textembedding-gecko``, so ≈4× the index memory). A reduced ``output_dimensionality`` returns shorter vectors that Google does **not** re-normalize; this is invisible under the COSINE metric (scale-invariant) but matters for inner-product / L2 — normalize yourself if your index needs it. Embeddings from different models (or dimensions) are not interchangeable — reindex when you change them. .. code-block:: python # Vertex AI backend from redisvl.utils.vectorize import GoogleGenAIVectorizer vectorizer = GoogleGenAIVectorizer( model="gemini-embedding-001", api_config={ "project_id": "your-gcp-project", # or set GOOGLE_CLOUD_PROJECT "location": "us-central1", # or set GOOGLE_CLOUD_LOCATION }, ) embedding = vectorizer.embed("Hello, world!") # Gemini Developer API backend vectorizer = GoogleGenAIVectorizer( model="gemini-embedding-001", api_config={"api_key": "your-gemini-api-key"}, # or set GEMINI_API_KEY ) # Reduced dimensions (Matryoshka) + caching from redisvl.extensions.cache.embeddings import EmbeddingsCache vectorizer = GoogleGenAIVectorizer( model="gemini-embedding-001", output_dimensionality=768, cache=EmbeddingsCache(name="genai_embeddings_cache"), ) # Asynchronous batch embedding embeddings = await vectorizer.aembed_many( ["Hello, world!", "How are you?"], batch_size=2 ) """ model_config = ConfigDict(arbitrary_types_allowed=True) def __init__( self, model: str = "gemini-embedding-001", api_config: dict[str, Any] | None = None, dtype: str = "float32", cache: "EmbeddingsCache | None" = None, task_type: str | None = None, output_dimensionality: int | None = None, **kwargs, ): """Initialize the Google GenAI vectorizer. Args: model (str): The embedding model to use. Defaults to 'gemini-embedding-001', which works on both backends. api_config (Optional[Dict]): Auth/client configuration. Recognized keys: ``backend`` ("vertex"|"gemini"), ``project_id``, ``location``, ``credentials`` (Vertex), and ``api_key`` (Gemini). Model-behavior options are the ``task_type``/``output_dimensionality`` arguments below, not ``api_config`` keys. Defaults to None. dtype (str): The default datatype to use when embedding text as byte arrays. Used when setting ``as_buffer=True``. Defaults to 'float32'. cache (Optional[EmbeddingsCache]): Optional cache for repeated inputs. task_type (Optional[str]): Default embedding task type (e.g. 'RETRIEVAL_DOCUMENT', 'RETRIEVAL_QUERY'). Overridable per call. output_dimensionality (Optional[int]): Request shorter (Matryoshka) embeddings of this width; sets ``dims`` accordingly. Fixed for the vectorizer's lifetime (cannot be overridden per call). Note Google does not re-normalize reduced vectors. **kwargs: Additional arguments forwarded to ``google.genai.Client`` (e.g. ``http_options``). Raises: ImportError: If the google-genai library is not installed. ValueError: If a backend cannot be resolved, or an invalid dtype is given. """ super().__init__(model=model, dtype=dtype, cache=cache) self._setup(api_config, task_type, output_dimensionality, **kwargs) def _setup( self, api_config: dict[str, Any] | None, task_type: str | None, output_dimensionality: int | None, **kwargs, ): """Build the default embed config, initialize the client, and set dims.""" # Default embed config is set BEFORE _set_model_dims so the dimension-probe # embed uses the same config (e.g. output_dimensionality) as real calls. self._embed_config: dict[str, Any] = {} if task_type is not None: self._embed_config["task_type"] = task_type if output_dimensionality is not None: self._embed_config["output_dimensionality"] = output_dimensionality self._initialize_client(api_config, **kwargs) self.dims = self._set_model_dims() def _initialize_client(self, api_config: dict[str, Any] | None, **kwargs): """Resolve the backend and construct a single google-genai client. Raises: ImportError: If the google-genai library is not installed. ValueError: If no backend can be resolved from config or environment. """ # Copy so we never mutate the caller's dict. api_config = dict(api_config or {}) try: from google import genai from google.genai.types import EmbedContentConfig except ImportError: raise ImportError( "GoogleGenAIVectorizer requires the google-genai library. " "Please install with `pip install redisvl[google-genai]`" ) self._config_cls = EmbedContentConfig backend = self._resolve_backend(api_config) if backend == "vertex": project = ( api_config.get("project_id") or os.getenv("GOOGLE_CLOUD_PROJECT") or os.getenv("GCP_PROJECT_ID") ) location = ( api_config.get("location") or os.getenv("GOOGLE_CLOUD_LOCATION") or os.getenv("GCP_LOCATION") ) if not location: raise ValueError( "Vertex AI backend selected but no location was provided. " "Set api_config['location'] or the GOOGLE_CLOUD_LOCATION " "(or GCP_LOCATION) environment variable." ) self._client = genai.Client( vertexai=True, project=project, location=location, credentials=api_config.get("credentials"), **kwargs, ) else: api_key = ( api_config.get("api_key") or os.getenv("GEMINI_API_KEY") or os.getenv("GOOGLE_API_KEY") ) self._client = genai.Client(api_key=api_key, **kwargs) self._backend = backend @staticmethod def _resolve_backend(api_config: dict[str, Any]) -> str: """Return 'vertex' or 'gemini' using explicit-beats-ambient precedence.""" # 1. Explicit override. explicit = api_config.get("backend") if explicit is not None: if explicit not in ("vertex", "gemini"): raise ValueError( f"Invalid api_config['backend']: {explicit!r}. " "Must be 'vertex' or 'gemini'." ) return explicit if "vertexai" in api_config: return "vertex" if api_config["vertexai"] else "gemini" # 2. Explicit credentials in api_config. if api_config.get("api_key"): return "gemini" if api_config.get("project_id"): return "vertex" # 3. Ambient environment (project beats key). if os.getenv("GOOGLE_CLOUD_PROJECT") or os.getenv("GCP_PROJECT_ID"): return "vertex" if os.getenv("GEMINI_API_KEY") or os.getenv("GOOGLE_API_KEY"): return "gemini" raise ValueError( "Could not resolve a Google backend. Provide Vertex AI config " "(api_config={'project_id': ..., 'location': ...} or set " "GOOGLE_CLOUD_PROJECT/GOOGLE_CLOUD_LOCATION [or GCP_PROJECT_ID/GCP_LOCATION] " "with Application Default Credentials), or a Gemini Developer API key " "(api_config={'api_key': ...} or set GEMINI_API_KEY/GOOGLE_API_KEY)." ) @property def backend(self) -> str: """The resolved backend: 'vertex' or 'gemini'.""" return self._backend def _set_model_dims(self) -> int: """Determine embedding dimensionality via a single probe embed.""" try: embedding = self._embed("dimension check") return len(embedding) except (KeyError, IndexError, AttributeError) as e: raise ValueError(f"Unexpected response from the Google GenAI API: {e}") except Exception as e: # pylint: disable=broad-except # Avoid interpolating provider/transport errors, which can embed # credentials, into the message. Chain the original for debugging. raise ValueError( "Error setting embedding model dimensions with the Google GenAI API. " "Verify the model name, backend selection, and credentials." ) from e def _build_config(self, extra: dict[str, Any]) -> Any: """Merge stored config defaults with per-call kwargs into an EmbedContentConfig. ``output_dimensionality`` is fixed at construction (it determines ``self.dims``), so it cannot be overridden per call — that would desync returned embeddings from the index's vector width. Other fields (e.g. ``task_type``) may vary per call. """ if "output_dimensionality" in extra: raise TypeError( "output_dimensionality cannot be overridden per call; set it on the " "GoogleGenAIVectorizer(...) constructor instead (it determines dims)." ) merged = {**self._embed_config, **extra} return self._config_cls(**merged) if merged else None @staticmethod def _embeddings_from_response(response: Any, expected: int) -> list: """Return the response embeddings, guarding against None / count mismatch.""" embeddings = response.embeddings if not embeddings or len(embeddings) != expected: count = 0 if not embeddings else len(embeddings) raise ValueError( f"Google GenAI API returned {count} embedding(s) for {expected} " "input(s)." ) return embeddings @staticmethod def _values(embedding: Any) -> list[float]: """Return an embedding's raw values, guarding against a null payload.""" if embedding.values is None: raise ValueError("No embedding returned from the Google GenAI API.") return list(embedding.values) @staticmethod def _validate_many(contents: list[str]) -> None: """Validate batch input without masking the validation error.""" if not isinstance(contents, list): raise TypeError( f"Input contents must be a list of strings to embed, got {type(contents)}" ) if not all(isinstance(c, str) for c in contents): raise TypeError("Input contents must be a list of strings to embed.") @retry( wait=wait_random_exponential(min=1, max=60), stop=stop_after_attempt(6), retry=retry_if_not_exception_type(TypeError), reraise=True, ) def _embed(self, content: str, **kwargs) -> list[float]: """Generate a vector embedding for a single string.""" if not isinstance(content, str): raise TypeError( f"Input content must be a string to embed, got {type(content)}" ) config = self._build_config(kwargs) response = self._client.models.embed_content( model=self.model, contents=content, config=config ) embeddings = self._embeddings_from_response(response, 1) return self._values(embeddings[0]) @retry( wait=wait_random_exponential(min=1, max=60), stop=stop_after_attempt(6), retry=retry_if_not_exception_type(TypeError), reraise=True, ) def _embed_many( self, contents: list[str], batch_size: int = 10, **kwargs ) -> list[list[float]]: """Generate vector embeddings for a batch of strings.""" self._validate_many(contents) config = self._build_config(kwargs) embeddings: list[list[float]] = [] for batch in self.batchify(contents, batch_size): response = self._client.models.embed_content( model=self.model, contents=batch, config=config ) batch_embeddings = self._embeddings_from_response(response, len(batch)) embeddings.extend(self._values(e) for e in batch_embeddings) return embeddings @retry( wait=wait_random_exponential(min=1, max=60), stop=stop_after_attempt(6), retry=retry_if_not_exception_type(TypeError), reraise=True, ) async def _aembed(self, content: str, **kwargs) -> list[float]: """Asynchronously generate a vector embedding for a single string.""" if not isinstance(content, str): raise TypeError( f"Input content must be a string to embed, got {type(content)}" ) config = self._build_config(kwargs) response = await self._client.aio.models.embed_content( model=self.model, contents=content, config=config ) embeddings = self._embeddings_from_response(response, 1) return self._values(embeddings[0]) @retry( wait=wait_random_exponential(min=1, max=60), stop=stop_after_attempt(6), retry=retry_if_not_exception_type(TypeError), reraise=True, ) async def _aembed_many( self, contents: list[str], batch_size: int = 10, **kwargs ) -> list[list[float]]: """Asynchronously generate vector embeddings for a batch of strings.""" self._validate_many(contents) config = self._build_config(kwargs) embeddings: list[list[float]] = [] for batch in self.batchify(contents, batch_size): response = await self._client.aio.models.embed_content( model=self.model, contents=batch, config=config ) batch_embeddings = self._embeddings_from_response(response, len(batch)) embeddings.extend(self._values(e) for e in batch_embeddings) return embeddings @property def type(self) -> str: return "google_genai"