From 4a9902da5c2d1162055daa0c2f80d5fe7a1d6aa7 Mon Sep 17 00:00:00 2001 From: flamerged <34665379+flamerged@users.noreply.github.com> Date: Wed, 27 May 2026 01:08:02 +0200 Subject: [PATCH] add zeroentropy embeddings provider --- .env.example | 9 +- hindsight-api-slim/hindsight_api/config.py | 74 ++++++ .../hindsight_api/engine/cross_encoder.py | 1 + .../hindsight_api/engine/embeddings.py | 220 +++++++++++++++- .../hindsight_api/engine/memory_engine.py | 12 +- .../engine/retain/embedding_utils.py | 41 ++- .../tests/test_zeroentropy_embeddings.py | 249 ++++++++++++++++++ .../docs/developer/configuration.md | 23 +- hindsight-docs/docs/developer/models.mdx | 15 ++ .../references/developer/configuration.md | 21 +- .../references/developer/models.md | 15 ++ 11 files changed, 666 insertions(+), 14 deletions(-) create mode 100644 hindsight-api-slim/tests/test_zeroentropy_embeddings.py diff --git a/.env.example b/.env.example index bd53a83b97..e8ad7c3441 100644 --- a/.env.example +++ b/.env.example @@ -65,7 +65,7 @@ HINDSIGHT_API_LOG_LEVEL=info # HINDSIGHT_API_VECTOR_EXTENSION=pgvectorscale # Auto-detects pg_diskann on Azure # Embeddings Configuration (Optional - uses local by default) -# Provider: "local" (default), "tei", "openai", "cohere", "google", "openrouter", "litellm", or "litellm-sdk" +# Provider: "local" (default), "tei", "openai", "cohere", "google", "openrouter", "zeroentropy", "litellm", or "litellm-sdk" # HINDSIGHT_API_EMBEDDINGS_PROVIDER=local # For local provider: # HINDSIGHT_API_EMBEDDINGS_LOCAL_MODEL=BAAI/bge-small-en-v1.5 @@ -77,6 +77,13 @@ HINDSIGHT_API_LOG_LEVEL=info # HINDSIGHT_API_EMBEDDINGS_OPENAI_API_KEY=sk-xxxx # HINDSIGHT_API_EMBEDDINGS_OPENAI_MODEL=text-embedding-3-small # HINDSIGHT_API_EMBEDDINGS_OPENAI_BASE_URL=https://api.openai.com/v1 +# For ZeroEntropy zembed-1: +# HINDSIGHT_API_EMBEDDINGS_PROVIDER=zeroentropy +# HINDSIGHT_API_EMBEDDINGS_ZEROENTROPY_API_KEY=ze-xxxx +# HINDSIGHT_API_EMBEDDINGS_ZEROENTROPY_MODEL=zembed-1 +# HINDSIGHT_API_EMBEDDINGS_ZEROENTROPY_DIMENSIONS=1280 +# HINDSIGHT_API_EMBEDDINGS_ZEROENTROPY_ENCODING_FORMAT=float +# HINDSIGHT_API_EMBEDDINGS_ZEROENTROPY_LATENCY=fast # # IMPORTANT: Embedding keys require provider-specific names: # HINDSIGHT_API_EMBEDDINGS_{PROVIDER}_{PARAMETER} diff --git a/hindsight-api-slim/hindsight_api/config.py b/hindsight-api-slim/hindsight_api/config.py index d036070dbe..8b6b4da408 100644 --- a/hindsight-api-slim/hindsight_api/config.py +++ b/hindsight-api-slim/hindsight_api/config.py @@ -228,6 +228,15 @@ def normalize_config_dict(config: dict[str, Any]) -> dict[str, Any]: ENV_RERANKER_OPENROUTER_API_KEY = "HINDSIGHT_API_RERANKER_OPENROUTER_API_KEY" ENV_RERANKER_OPENROUTER_MODEL = "HINDSIGHT_API_RERANKER_OPENROUTER_MODEL" +# ZeroEntropy configuration (embeddings) +ENV_EMBEDDINGS_ZEROENTROPY_API_KEY = "HINDSIGHT_API_EMBEDDINGS_ZEROENTROPY_API_KEY" +ENV_EMBEDDINGS_ZEROENTROPY_MODEL = "HINDSIGHT_API_EMBEDDINGS_ZEROENTROPY_MODEL" +ENV_EMBEDDINGS_ZEROENTROPY_BASE_URL = "HINDSIGHT_API_EMBEDDINGS_ZEROENTROPY_BASE_URL" +ENV_EMBEDDINGS_ZEROENTROPY_DIMENSIONS = "HINDSIGHT_API_EMBEDDINGS_ZEROENTROPY_DIMENSIONS" +ENV_EMBEDDINGS_ZEROENTROPY_ENCODING_FORMAT = "HINDSIGHT_API_EMBEDDINGS_ZEROENTROPY_ENCODING_FORMAT" +ENV_EMBEDDINGS_ZEROENTROPY_LATENCY = "HINDSIGHT_API_EMBEDDINGS_ZEROENTROPY_LATENCY" +ENV_EMBEDDINGS_ZEROENTROPY_BATCH_SIZE = "HINDSIGHT_API_EMBEDDINGS_ZEROENTROPY_BATCH_SIZE" + # Deprecated: Legacy shared Cohere API key (for backward compatibility) ENV_COHERE_API_KEY = "HINDSIGHT_API_COHERE_API_KEY" @@ -555,6 +564,16 @@ def normalize_config_dict(config: dict[str, Any]) -> dict[str, Any]: DEFAULT_EMBEDDINGS_OPENROUTER_MODEL = "perplexity/pplx-embed-v1-0.6b" DEFAULT_RERANKER_OPENROUTER_MODEL = "cohere/rerank-v3.5" +# ZeroEntropy defaults +DEFAULT_EMBEDDINGS_ZEROENTROPY_MODEL = "zembed-1" +DEFAULT_EMBEDDINGS_ZEROENTROPY_BASE_URL = "https://api.zeroentropy.dev" +# ZeroEntropy's API default is 2560, but Hindsight defaults to 1280 so the +# provider works with pgvector HNSW's 2000-dimension index limit out of the box. +DEFAULT_EMBEDDINGS_ZEROENTROPY_DIMENSIONS = 1280 +DEFAULT_EMBEDDINGS_ZEROENTROPY_ENCODING_FORMAT = "float" +DEFAULT_EMBEDDINGS_ZEROENTROPY_LATENCY = None +DEFAULT_EMBEDDINGS_ZEROENTROPY_BATCH_SIZE = 100 + DEFAULT_RERANKER_ZEROENTROPY_MODEL = "zerank-2" DEFAULT_RERANKER_SILICONFLOW_MODEL = "BAAI/bge-reranker-v2-m3" @@ -828,6 +847,17 @@ def _parse_optional_positive_int(name: str, raw: str | None) -> int | None: return _parse_positive_int(name, raw, 1) +def _parse_optional_choice(name: str, raw: str | None, allowed: frozenset[str]) -> str | None: + """Parse an optional string env var constrained to a small allowlist.""" + if raw is None or raw == "": + return None + normalized = raw.lower() + if normalized not in allowed: + values = ", ".join(sorted(allowed)) + raise ValueError(f"{name} must be one of {values}, got {raw!r}") + return normalized + + def _validate_extraction_mode(mode: str) -> str: """Validate and normalize extraction mode.""" mode_lower = mode.lower() @@ -1229,6 +1259,13 @@ class HindsightConfig: # Keep at the end of the dataclass; Python forbids non-default fields after default fields. embeddings_openai_batch_size: int = DEFAULT_EMBEDDINGS_OPENAI_BATCH_SIZE embeddings_openai_dimensions: int | None = None + embeddings_zeroentropy_api_key: str | None = None + embeddings_zeroentropy_model: str = DEFAULT_EMBEDDINGS_ZEROENTROPY_MODEL + embeddings_zeroentropy_base_url: str = DEFAULT_EMBEDDINGS_ZEROENTROPY_BASE_URL + embeddings_zeroentropy_dimensions: int = DEFAULT_EMBEDDINGS_ZEROENTROPY_DIMENSIONS + embeddings_zeroentropy_encoding_format: str = DEFAULT_EMBEDDINGS_ZEROENTROPY_ENCODING_FORMAT + embeddings_zeroentropy_batch_size: int = DEFAULT_EMBEDDINGS_ZEROENTROPY_BATCH_SIZE + embeddings_zeroentropy_latency: str | None = DEFAULT_EMBEDDINGS_ZEROENTROPY_LATENCY # Class-level sets for configuration categorization @@ -1252,6 +1289,7 @@ class HindsightConfig: "embeddings_tei_base_url", "reranker_tei_base_url", "reranker_cohere_base_url", + "embeddings_zeroentropy_base_url", "reranker_zeroentropy_base_url", "reranker_siliconflow_base_url", # Service Account Keys @@ -1260,6 +1298,7 @@ class HindsightConfig: "reranker_google_service_account_key", # Embeddings API keys "embeddings_gemini_api_key", + "embeddings_zeroentropy_api_key", # File storage credentials "file_storage_s3_access_key_id", "file_storage_s3_secret_access_key", @@ -1429,6 +1468,11 @@ def validate(self) -> None: f"provider: {self.retain_llm_provider or self.llm_provider})" ) + if self.embeddings_provider.lower() == "zeroentropy": + valid_dimensions = frozenset({2560, 1280, 640, 320, 160, 80, 40}) + if self.embeddings_zeroentropy_dimensions not in valid_dimensions: + values = ", ".join(str(dim) for dim in sorted(valid_dimensions, reverse=True)) + raise ValueError(f"{ENV_EMBEDDINGS_ZEROENTROPY_DIMENSIONS} must be one of {values}") # Warn if local ML dependencies are missing when configured. # Don't hard-fail here — the actual ImportError fires at model init time # with a clear message. This early warning catches it before startup proceeds. @@ -1624,6 +1668,36 @@ def from_env(cls) -> "HindsightConfig": or os.getenv(ENV_OPENROUTER_API_KEY) or os.getenv(ENV_LLM_API_KEY), embeddings_openrouter_model=os.getenv(ENV_EMBEDDINGS_OPENROUTER_MODEL, DEFAULT_EMBEDDINGS_OPENROUTER_MODEL), + # ZeroEntropy embeddings + embeddings_zeroentropy_api_key=os.getenv(ENV_EMBEDDINGS_ZEROENTROPY_API_KEY) + or os.getenv("ZEROENTROPY_API_KEY"), + embeddings_zeroentropy_model=os.getenv( + ENV_EMBEDDINGS_ZEROENTROPY_MODEL, DEFAULT_EMBEDDINGS_ZEROENTROPY_MODEL + ), + embeddings_zeroentropy_base_url=os.getenv( + ENV_EMBEDDINGS_ZEROENTROPY_BASE_URL, DEFAULT_EMBEDDINGS_ZEROENTROPY_BASE_URL + ), + embeddings_zeroentropy_dimensions=_parse_positive_int( + ENV_EMBEDDINGS_ZEROENTROPY_DIMENSIONS, + os.getenv(ENV_EMBEDDINGS_ZEROENTROPY_DIMENSIONS), + DEFAULT_EMBEDDINGS_ZEROENTROPY_DIMENSIONS, + ), + embeddings_zeroentropy_encoding_format=_parse_optional_choice( + ENV_EMBEDDINGS_ZEROENTROPY_ENCODING_FORMAT, + os.getenv(ENV_EMBEDDINGS_ZEROENTROPY_ENCODING_FORMAT) or DEFAULT_EMBEDDINGS_ZEROENTROPY_ENCODING_FORMAT, + frozenset({"float", "base64"}), + ) + or DEFAULT_EMBEDDINGS_ZEROENTROPY_ENCODING_FORMAT, + embeddings_zeroentropy_latency=_parse_optional_choice( + ENV_EMBEDDINGS_ZEROENTROPY_LATENCY, + os.getenv(ENV_EMBEDDINGS_ZEROENTROPY_LATENCY), + frozenset({"fast", "slow"}), + ), + embeddings_zeroentropy_batch_size=_parse_positive_int( + ENV_EMBEDDINGS_ZEROENTROPY_BATCH_SIZE, + os.getenv(ENV_EMBEDDINGS_ZEROENTROPY_BATCH_SIZE), + DEFAULT_EMBEDDINGS_ZEROENTROPY_BATCH_SIZE, + ), # LiteLLM embeddings (with backward-compatible fallback to shared config) embeddings_litellm_api_base=os.getenv(ENV_EMBEDDINGS_LITELLM_API_BASE) or os.getenv(ENV_LITELLM_API_BASE, DEFAULT_LITELLM_API_BASE), diff --git a/hindsight-api-slim/hindsight_api/engine/cross_encoder.py b/hindsight-api-slim/hindsight_api/engine/cross_encoder.py index b751320f65..405ecb71c2 100644 --- a/hindsight-api-slim/hindsight_api/engine/cross_encoder.py +++ b/hindsight-api-slim/hindsight_api/engine/cross_encoder.py @@ -1713,6 +1713,7 @@ def create_cross_encoder_from_env() -> CrossEncoderModel: return ZeroEntropyCrossEncoder( api_key=api_key, model=config.reranker_zeroentropy_model, + base_url=config.reranker_zeroentropy_base_url, ) elif provider == "siliconflow": api_key = config.reranker_siliconflow_api_key diff --git a/hindsight-api-slim/hindsight_api/engine/embeddings.py b/hindsight-api-slim/hindsight_api/engine/embeddings.py index 0b3a948b92..a47f15e861 100644 --- a/hindsight-api-slim/hindsight_api/engine/embeddings.py +++ b/hindsight-api-slim/hindsight_api/engine/embeddings.py @@ -9,15 +9,19 @@ Configuration via environment variables - see hindsight_api.config for all env var names. """ +import base64 import json import logging import os +import struct import warnings from abc import ABC, abstractmethod from pathlib import Path +from typing import Literal, cast from urllib.parse import parse_qs, urlparse, urlunparse import httpx +from pydantic import BaseModel from ..config import ( DEFAULT_EMBEDDINGS_COHERE_MODEL, @@ -29,6 +33,12 @@ DEFAULT_EMBEDDINGS_LOCAL_TRUST_REMOTE_CODE, DEFAULT_EMBEDDINGS_OPENAI_MODEL, DEFAULT_EMBEDDINGS_PROVIDER, + DEFAULT_EMBEDDINGS_ZEROENTROPY_BASE_URL, + DEFAULT_EMBEDDINGS_ZEROENTROPY_BATCH_SIZE, + DEFAULT_EMBEDDINGS_ZEROENTROPY_DIMENSIONS, + DEFAULT_EMBEDDINGS_ZEROENTROPY_ENCODING_FORMAT, + DEFAULT_EMBEDDINGS_ZEROENTROPY_LATENCY, + DEFAULT_EMBEDDINGS_ZEROENTROPY_MODEL, DEFAULT_LITELLM_API_BASE, ENV_EMBEDDINGS_COHERE_API_KEY, ENV_EMBEDDINGS_GEMINI_API_KEY, @@ -40,12 +50,45 @@ ENV_EMBEDDINGS_OPENAI_MODEL, ENV_EMBEDDINGS_PROVIDER, ENV_EMBEDDINGS_TEI_URL, + ENV_EMBEDDINGS_ZEROENTROPY_API_KEY, + ENV_EMBEDDINGS_ZEROENTROPY_DIMENSIONS, + ENV_EMBEDDINGS_ZEROENTROPY_ENCODING_FORMAT, ENV_LLM_API_KEY, ) logger = logging.getLogger(__name__) +ZeroEntropyInputType = Literal["document", "query"] +ZeroEntropyLatency = Literal["fast", "slow"] +ZeroEntropyEncodingFormat = Literal["float", "base64"] + + +class _ZeroEntropyEmbedRequest(BaseModel): + """Typed request body for ZeroEntropy's non-OpenAI-compatible embed endpoint.""" + + model: str + input: list[str] + input_type: ZeroEntropyInputType + dimensions: int + encoding_format: ZeroEntropyEncodingFormat = "float" + latency: ZeroEntropyLatency | None = None + + +class _ZeroEntropyEmbedResult(BaseModel): + embedding: list[float] | str + + +class _ZeroEntropyEmbedUsage(BaseModel): + total_bytes: int | None = None + total_tokens: int | None = None + + +class _ZeroEntropyEmbedResponse(BaseModel): + results: list[_ZeroEntropyEmbedResult] + usage: _ZeroEntropyEmbedUsage | None = None + + class Embeddings(ABC): """ Abstract base class for embedding generation. @@ -89,6 +132,14 @@ def encode(self, texts: list[str]) -> list[list[float]]: """ pass + def encode_query(self, texts: list[str]) -> list[list[float]]: + """Generate embeddings for query text. Providers without asymmetric embeddings use encode().""" + return self.encode(texts) + + def encode_documents(self, texts: list[str]) -> list[list[float]]: + """Generate embeddings for stored document text. Providers without asymmetric embeddings use encode().""" + return self.encode(texts) + class LocalSTEmbeddings(Embeddings): """ @@ -695,6 +746,157 @@ def encode(self, texts: list[str]) -> list[list[float]]: return all_embeddings +class ZeroEntropyEmbeddings(Embeddings): + """ + ZeroEntropy embeddings implementation using the zembed API. + + ZeroEntropy's embeddings endpoint is not OpenAI-compatible: it lives at + /v1/models/embed and requires provider-specific parameters such as + input_type. Hindsight stores document-side vectors and uses query-side + vectors during recall, so this provider exposes explicit encode_documents() + and encode_query() helpers while keeping encode() as document-side default. + """ + + VALID_DIMENSIONS = frozenset({2560, 1280, 640, 320, 160, 80, 40}) + VALID_ENCODING_FORMATS = frozenset({"float", "base64"}) + VALID_LATENCIES = frozenset({"fast", "slow"}) + DEFAULT_BASE_URL = DEFAULT_EMBEDDINGS_ZEROENTROPY_BASE_URL + EMBED_PATH = "/v1/models/embed" + + def __init__( + self, + api_key: str, + model: str = DEFAULT_EMBEDDINGS_ZEROENTROPY_MODEL, + base_url: str = DEFAULT_EMBEDDINGS_ZEROENTROPY_BASE_URL, + dimensions: int = DEFAULT_EMBEDDINGS_ZEROENTROPY_DIMENSIONS, + batch_size: int = DEFAULT_EMBEDDINGS_ZEROENTROPY_BATCH_SIZE, + encoding_format: str = DEFAULT_EMBEDDINGS_ZEROENTROPY_ENCODING_FORMAT, + latency: str | None = DEFAULT_EMBEDDINGS_ZEROENTROPY_LATENCY, + timeout: float = 60.0, + ): + if dimensions not in self.VALID_DIMENSIONS: + valid = ", ".join(str(dim) for dim in sorted(self.VALID_DIMENSIONS, reverse=True)) + raise ValueError(f"{ENV_EMBEDDINGS_ZEROENTROPY_DIMENSIONS} must be one of {valid}, got {dimensions}") + if batch_size < 1: + raise ValueError("ZeroEntropy embeddings batch_size must be >= 1") + if encoding_format not in self.VALID_ENCODING_FORMATS: + valid_formats = ", ".join(sorted(self.VALID_ENCODING_FORMATS)) + raise ValueError( + f"{ENV_EMBEDDINGS_ZEROENTROPY_ENCODING_FORMAT} must be one of {valid_formats}, got {encoding_format!r}" + ) + if latency is not None and latency not in self.VALID_LATENCIES: + valid_latencies = ", ".join(sorted(self.VALID_LATENCIES)) + raise ValueError(f"ZeroEntropy embeddings latency must be one of {valid_latencies}, got {latency!r}") + + self.api_key = api_key + self.model = model + self.base_url = base_url.rstrip("/") + self.dimensions = dimensions + self.batch_size = batch_size + self.encoding_format = cast(ZeroEntropyEncodingFormat, encoding_format) + self.latency = cast(ZeroEntropyLatency | None, latency) + self.timeout = timeout + self._client: httpx.Client | None = None + self._dimension: int | None = None + + @property + def provider_name(self) -> str: + return "zeroentropy" + + @property + def dimension(self) -> int: + if self._dimension is None: + raise RuntimeError("Embeddings not initialized. Call initialize() first.") + return self._dimension + + async def initialize(self) -> None: + """Initialize the ZeroEntropy HTTP client.""" + if self._client is not None: + return + + logger.info( + f"Embeddings: initializing ZeroEntropy provider with model {self.model} " + f"(dim: {self.dimensions}, batch_size={self.batch_size})" + ) + self._client = httpx.Client( + timeout=self.timeout, + headers={ + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + }, + ) + # zembed-1 dimensions are explicit Matryoshka truncation steps. Avoid a + # startup probe so boot does not burn quota or require a throwaway input. + self._dimension = self.dimensions + logger.info(f"Embeddings: ZeroEntropy provider initialized (model: {self.model}, dim: {self._dimension})") + + def encode(self, texts: list[str]) -> list[list[float]]: + """Generate document-side embeddings for backwards-compatible callers.""" + return self.encode_documents(texts) + + def encode_documents(self, texts: list[str]) -> list[list[float]]: + """Generate document-side embeddings for retained content.""" + return self._encode_with_input_type(texts, "document") + + def encode_query(self, texts: list[str]) -> list[list[float]]: + """Generate query-side embeddings for recall/search queries.""" + return self._encode_with_input_type(texts, "query") + + def _encode_with_input_type(self, texts: list[str], input_type: ZeroEntropyInputType) -> list[list[float]]: + if self._client is None: + raise RuntimeError("Embeddings not initialized. Call initialize() first.") + + if not texts: + return [] + + all_embeddings: list[list[float]] = [] + embed_url = self._embed_url() + + for i in range(0, len(texts), self.batch_size): + batch = texts[i : i + self.batch_size] + request = _ZeroEntropyEmbedRequest( + model=self.model, + input=batch, + input_type=input_type, + dimensions=self.dimensions, + encoding_format=self.encoding_format, + latency=self.latency, + ) + + try: + response = self._client.post(embed_url, json=request.model_dump(exclude_none=True)) + response.raise_for_status() + except httpx.HTTPError as e: + raise RuntimeError(f"ZeroEntropy embedding request failed: {e}") from e + + parsed = _ZeroEntropyEmbedResponse.model_validate(response.json()) + if len(parsed.results) != len(batch): + raise RuntimeError( + f"ZeroEntropy returned {len(parsed.results)} embeddings for {len(batch)} input texts; " + "expected exact 1:1 alignment" + ) + all_embeddings.extend(self._parse_embedding(result.embedding) for result in parsed.results) + + return all_embeddings + + def _embed_url(self) -> str: + if self.base_url.endswith(self.EMBED_PATH): + return self.base_url + if self.base_url.endswith("/v1"): + return f"{self.base_url}/models/embed" + return f"{self.base_url}{self.EMBED_PATH}" + + @staticmethod + def _parse_embedding(embedding: list[float] | str) -> list[float]: + if not isinstance(embedding, str): + return embedding + + raw = base64.b64decode(embedding) + if len(raw) % 4 != 0: + raise RuntimeError("ZeroEntropy returned invalid base64 embedding length") + return list(struct.unpack(f"<{len(raw) // 4}f", raw)) + + class LiteLLMEmbeddings(Embeddings): """ LiteLLM embeddings implementation using LiteLLM proxy's /embeddings endpoint. @@ -1227,6 +1429,22 @@ def create_embeddings_from_env() -> Embeddings: base_url="https://openrouter.ai/api/v1", batch_size=config.embeddings_openai_batch_size, ) + elif provider == "zeroentropy": + api_key = config.embeddings_zeroentropy_api_key + if not api_key: + raise ValueError( + f"{ENV_EMBEDDINGS_ZEROENTROPY_API_KEY} or ZEROENTROPY_API_KEY is required " + f"when {ENV_EMBEDDINGS_PROVIDER} is 'zeroentropy'" + ) + return ZeroEntropyEmbeddings( + api_key=api_key, + model=config.embeddings_zeroentropy_model, + base_url=config.embeddings_zeroentropy_base_url or DEFAULT_EMBEDDINGS_ZEROENTROPY_BASE_URL, + dimensions=config.embeddings_zeroentropy_dimensions, + batch_size=config.embeddings_zeroentropy_batch_size, + encoding_format=config.embeddings_zeroentropy_encoding_format, + latency=config.embeddings_zeroentropy_latency, + ) elif provider == "cohere": api_key = config.embeddings_cohere_api_key if not api_key: @@ -1275,5 +1493,5 @@ def create_embeddings_from_env() -> Embeddings: raise ValueError( f"Unknown embeddings provider: {provider}. " f"Supported: 'local', 'tei', 'openai', 'openai-codex', 'openrouter', 'cohere', 'google', " - f"'litellm', 'litellm-sdk'" + f"'zeroentropy', 'litellm', 'litellm-sdk'" ) diff --git a/hindsight-api-slim/hindsight_api/engine/memory_engine.py b/hindsight-api-slim/hindsight_api/engine/memory_engine.py index 86bd5033dd..0613da28ef 100644 --- a/hindsight-api-slim/hindsight_api/engine/memory_engine.py +++ b/hindsight-api-slim/hindsight_api/engine/memory_engine.py @@ -3166,7 +3166,11 @@ async def _search_with_retries( embedding_span.set_attribute("hindsight.query", query[:100]) try: - query_embeddings = await embedding_utils.generate_embeddings_batch(self.embeddings, [query]) + query_embeddings = await embedding_utils.generate_embeddings_batch( + self.embeddings, + [query], + input_type="query", + ) query_embedding = query_embeddings[0] step_duration = time.time() - step_start log_buffer.append(f" [1] Generate query embedding: {step_duration:.3f}s") @@ -6264,7 +6268,11 @@ async def reflect_async( async def search_mental_models_fn(q: str, max_results: int = 5) -> dict[str, Any]: # Generate embedding for the query - embeddings = await embedding_utils.generate_embeddings_batch(self.embeddings, [q]) + embeddings = await embedding_utils.generate_embeddings_batch( + self.embeddings, + [q], + input_type="query", + ) query_embedding = embeddings[0] async with backend.acquire() as conn: return await tool_search_mental_models( diff --git a/hindsight-api-slim/hindsight_api/engine/retain/embedding_utils.py b/hindsight-api-slim/hindsight_api/engine/retain/embedding_utils.py index 6978eef515..be1e003fbc 100644 --- a/hindsight-api-slim/hindsight_api/engine/retain/embedding_utils.py +++ b/hindsight-api-slim/hindsight_api/engine/retain/embedding_utils.py @@ -4,29 +4,57 @@ import asyncio import logging +from typing import Literal, Protocol logger = logging.getLogger(__name__) +EmbeddingInputType = Literal["document", "query"] -def generate_embedding(embeddings_backend, text: str) -> list[float]: + +class EmbeddingsBackend(Protocol): + def encode(self, texts: list[str]) -> list[list[float]]: ... + + def encode_query(self, texts: list[str]) -> list[list[float]]: ... + + def encode_documents(self, texts: list[str]) -> list[list[float]]: ... + + +def generate_embedding( + embeddings_backend: EmbeddingsBackend, text: str, input_type: EmbeddingInputType = "document" +) -> list[float]: """ Generate embedding for text using the provided embeddings backend. Args: embeddings_backend: Embeddings instance to use for encoding text: Text to embed + input_type: Whether text is retained document text or recall/search query text. Returns: Embedding vector (dimension depends on embeddings backend) """ try: - embeddings = embeddings_backend.encode([text]) + embeddings = _encode_with_input_type(embeddings_backend, [text], input_type) return embeddings[0] except Exception as e: raise Exception(f"Failed to generate embedding: {str(e)}") -async def generate_embeddings_batch(embeddings_backend, texts: list[str]) -> list[list[float]]: +def _encode_with_input_type( + embeddings_backend: EmbeddingsBackend, texts: list[str], input_type: EmbeddingInputType +) -> list[list[float]]: + encode_query = getattr(type(embeddings_backend), "encode_query", None) + if input_type == "query" and callable(encode_query): + return embeddings_backend.encode_query(texts) + encode_documents = getattr(type(embeddings_backend), "encode_documents", None) + if input_type == "document" and callable(encode_documents): + return embeddings_backend.encode_documents(texts) + return embeddings_backend.encode(texts) + + +async def generate_embeddings_batch( + embeddings_backend: EmbeddingsBackend, texts: list[str], input_type: EmbeddingInputType = "document" +) -> list[list[float]]: """ Generate embeddings for multiple texts using the provided embeddings backend. @@ -36,17 +64,14 @@ async def generate_embeddings_batch(embeddings_backend, texts: list[str]) -> lis Args: embeddings_backend: Embeddings instance to use for encoding texts: List of texts to embed + input_type: Whether texts are retained documents or recall/search queries. Returns: List of embeddings in same order as input texts """ try: loop = asyncio.get_event_loop() - embeddings = await loop.run_in_executor( - None, - embeddings_backend.encode, - texts, - ) + embeddings = await loop.run_in_executor(None, _encode_with_input_type, embeddings_backend, texts, input_type) except Exception as e: raise Exception(f"Failed to generate batch embeddings: {str(e)}") diff --git a/hindsight-api-slim/tests/test_zeroentropy_embeddings.py b/hindsight-api-slim/tests/test_zeroentropy_embeddings.py new file mode 100644 index 0000000000..53c391e6f7 --- /dev/null +++ b/hindsight-api-slim/tests/test_zeroentropy_embeddings.py @@ -0,0 +1,249 @@ +"""Tests for the native ZeroEntropy zembed embeddings provider.""" + +import base64 +import struct + +import httpx +import pytest +from pydantic import BaseModel + +ZEROENTROPY_ENV_VARS = [ + "HINDSIGHT_API_EMBEDDINGS_PROVIDER", + "HINDSIGHT_API_EMBEDDINGS_ZEROENTROPY_API_KEY", + "HINDSIGHT_API_EMBEDDINGS_ZEROENTROPY_MODEL", + "HINDSIGHT_API_EMBEDDINGS_ZEROENTROPY_BASE_URL", + "HINDSIGHT_API_EMBEDDINGS_ZEROENTROPY_DIMENSIONS", + "HINDSIGHT_API_EMBEDDINGS_ZEROENTROPY_ENCODING_FORMAT", + "HINDSIGHT_API_EMBEDDINGS_ZEROENTROPY_BATCH_SIZE", + "HINDSIGHT_API_EMBEDDINGS_ZEROENTROPY_LATENCY", + "HINDSIGHT_API_RERANKER_PROVIDER", + "HINDSIGHT_API_RERANKER_ZEROENTROPY_API_KEY", + "HINDSIGHT_API_RERANKER_ZEROENTROPY_MODEL", + "HINDSIGHT_API_RERANKER_ZEROENTROPY_BASE_URL", + "ZEROENTROPY_API_KEY", +] + + +class CapturedZeroEntropyEmbedRequest(BaseModel): + model: str + input: list[str] + input_type: str + dimensions: int + encoding_format: str + latency: str | None = None + + +@pytest.fixture(autouse=True) +def clean_zeroentropy_env(monkeypatch): + from hindsight_api.config import clear_config_cache + + for env_var in ZEROENTROPY_ENV_VARS: + monkeypatch.delenv(env_var, raising=False) + monkeypatch.setenv("HINDSIGHT_API_LLM_PROVIDER", "mock") + monkeypatch.setenv("HINDSIGHT_API_RERANKER_PROVIDER", "rrf") + monkeypatch.setenv("HINDSIGHT_API_EMBEDDINGS_PROVIDER", "zeroentropy") + clear_config_cache() + + yield + + clear_config_cache() + + +def test_zeroentropy_config_defaults(): + from hindsight_api.config import HindsightConfig + + config = HindsightConfig.from_env() + + assert config.embeddings_zeroentropy_model == "zembed-1" + assert config.embeddings_zeroentropy_base_url == "https://api.zeroentropy.dev" + assert config.embeddings_zeroentropy_dimensions == 1280 + assert config.embeddings_zeroentropy_encoding_format == "float" + assert config.embeddings_zeroentropy_batch_size == 100 + assert config.embeddings_zeroentropy_latency is None + + +def test_zeroentropy_create_from_env(monkeypatch): + from hindsight_api.engine.embeddings import ZeroEntropyEmbeddings, create_embeddings_from_env + + monkeypatch.setenv("HINDSIGHT_API_EMBEDDINGS_PROVIDER", "zeroentropy") + monkeypatch.setenv("HINDSIGHT_API_EMBEDDINGS_ZEROENTROPY_API_KEY", "ze-test") + monkeypatch.setenv("HINDSIGHT_API_EMBEDDINGS_ZEROENTROPY_MODEL", "zembed-1") + monkeypatch.setenv("HINDSIGHT_API_EMBEDDINGS_ZEROENTROPY_BASE_URL", "https://ze.example") + monkeypatch.setenv("HINDSIGHT_API_EMBEDDINGS_ZEROENTROPY_DIMENSIONS", "640") + monkeypatch.setenv("HINDSIGHT_API_EMBEDDINGS_ZEROENTROPY_ENCODING_FORMAT", "base64") + monkeypatch.setenv("HINDSIGHT_API_EMBEDDINGS_ZEROENTROPY_BATCH_SIZE", "2") + monkeypatch.setenv("HINDSIGHT_API_EMBEDDINGS_ZEROENTROPY_LATENCY", "fast") + + embeddings = create_embeddings_from_env() + + assert isinstance(embeddings, ZeroEntropyEmbeddings) + assert embeddings.api_key == "ze-test" + assert embeddings.model == "zembed-1" + assert embeddings.base_url == "https://ze.example" + assert embeddings.dimensions == 640 + assert embeddings.encoding_format == "base64" + assert embeddings.batch_size == 2 + assert embeddings.latency == "fast" + + +def test_zeroentropy_create_from_env_requires_api_key(monkeypatch): + from hindsight_api.engine.embeddings import create_embeddings_from_env + + monkeypatch.setenv("HINDSIGHT_API_EMBEDDINGS_PROVIDER", "zeroentropy") + + with pytest.raises(ValueError, match="HINDSIGHT_API_EMBEDDINGS_ZEROENTROPY_API_KEY"): + create_embeddings_from_env() + + +def test_zeroentropy_create_from_env_uses_standard_api_key_env(monkeypatch): + from hindsight_api.engine.embeddings import ZeroEntropyEmbeddings, create_embeddings_from_env + + monkeypatch.setenv("HINDSIGHT_API_EMBEDDINGS_PROVIDER", "zeroentropy") + monkeypatch.setenv("ZEROENTROPY_API_KEY", "ze-standard") + + embeddings = create_embeddings_from_env() + + assert isinstance(embeddings, ZeroEntropyEmbeddings) + assert embeddings.api_key == "ze-standard" + + +def test_zeroentropy_rejects_invalid_dimension(monkeypatch): + from hindsight_api.engine.embeddings import create_embeddings_from_env + + monkeypatch.setenv("HINDSIGHT_API_EMBEDDINGS_PROVIDER", "zeroentropy") + monkeypatch.setenv("HINDSIGHT_API_EMBEDDINGS_ZEROENTROPY_API_KEY", "ze-test") + monkeypatch.setenv("HINDSIGHT_API_EMBEDDINGS_ZEROENTROPY_DIMENSIONS", "1024") + + with pytest.raises(ValueError, match="must be one of"): + create_embeddings_from_env() + + +def test_zeroentropy_rejects_invalid_encoding_format(monkeypatch): + from hindsight_api.engine.embeddings import create_embeddings_from_env + + monkeypatch.setenv("HINDSIGHT_API_EMBEDDINGS_PROVIDER", "zeroentropy") + monkeypatch.setenv("HINDSIGHT_API_EMBEDDINGS_ZEROENTROPY_API_KEY", "ze-test") + monkeypatch.setenv("HINDSIGHT_API_EMBEDDINGS_ZEROENTROPY_ENCODING_FORMAT", "binary") + + with pytest.raises(ValueError, match="HINDSIGHT_API_EMBEDDINGS_ZEROENTROPY_ENCODING_FORMAT"): + create_embeddings_from_env() + + +def test_zeroentropy_encode_documents_batches_and_sends_expected_payload(): + from hindsight_api.engine.embeddings import ZeroEntropyEmbeddings + + requests: list[CapturedZeroEntropyEmbedRequest] = [] + + def handler(request: httpx.Request) -> httpx.Response: + body = CapturedZeroEntropyEmbedRequest.model_validate_json(request.content) + requests.append(body) + assert str(request.url) == "https://api.zeroentropy.dev/v1/models/embed" + assert request.headers["authorization"] == "Bearer ze-test" + return httpx.Response( + 200, + json={"results": [{"embedding": [float(i), 0.0]} for i, _ in enumerate(body.input)]}, + ) + + embeddings = ZeroEntropyEmbeddings(api_key="ze-test", dimensions=1280, batch_size=2, latency="fast") + embeddings._client = httpx.Client( + transport=httpx.MockTransport(handler), + headers={"Authorization": "Bearer ze-test", "Content-Type": "application/json"}, + ) + embeddings._dimension = 1280 + + vectors = embeddings.encode_documents(["alpha", "beta", "gamma"]) + + assert len(vectors) == 3 + assert [request.input for request in requests] == [["alpha", "beta"], ["gamma"]] + assert all(request.model == "zembed-1" for request in requests) + assert all(request.input_type == "document" for request in requests) + assert all(request.dimensions == 1280 for request in requests) + assert all(request.encoding_format == "float" for request in requests) + assert all(request.latency == "fast" for request in requests) + + +def test_zeroentropy_encode_query_sends_query_input_type(): + from hindsight_api.engine.embeddings import ZeroEntropyEmbeddings + + seen_input_types: list[str] = [] + + def handler(request: httpx.Request) -> httpx.Response: + body = CapturedZeroEntropyEmbedRequest.model_validate_json(request.content) + seen_input_types.append(body.input_type) + return httpx.Response(200, json={"results": [{"embedding": [1.0, 2.0]}]}) + + embeddings = ZeroEntropyEmbeddings(api_key="ze-test", dimensions=1280) + embeddings._client = httpx.Client( + transport=httpx.MockTransport(handler), + headers={"Authorization": "Bearer ze-test", "Content-Type": "application/json"}, + ) + embeddings._dimension = 1280 + + assert embeddings.encode_query(["where is this?"]) == [[1.0, 2.0]] + assert seen_input_types == ["query"] + + +def test_zeroentropy_base64_response_is_decoded(): + from hindsight_api.engine.embeddings import ZeroEntropyEmbeddings + + encoded = base64.b64encode(struct.pack("<2f", 0.25, 0.5)).decode("ascii") + + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={"results": [{"embedding": encoded}]}) + + embeddings = ZeroEntropyEmbeddings(api_key="ze-test", dimensions=1280, encoding_format="base64") + embeddings._client = httpx.Client( + transport=httpx.MockTransport(handler), + headers={"Authorization": "Bearer ze-test", "Content-Type": "application/json"}, + ) + embeddings._dimension = 1280 + + assert embeddings.encode_documents(["alpha"]) == [[0.25, 0.5]] + + +def test_zeroentropy_reranker_create_from_env_uses_base_url(monkeypatch): + from hindsight_api.config import clear_config_cache + from hindsight_api.engine.cross_encoder import ZeroEntropyCrossEncoder, create_cross_encoder_from_env + + monkeypatch.setenv("HINDSIGHT_API_RERANKER_PROVIDER", "zeroentropy") + monkeypatch.setenv("HINDSIGHT_API_RERANKER_ZEROENTROPY_API_KEY", "ze-test") + monkeypatch.setenv("HINDSIGHT_API_RERANKER_ZEROENTROPY_BASE_URL", "https://rerank.example") + clear_config_cache() + + encoder = create_cross_encoder_from_env() + + assert isinstance(encoder, ZeroEntropyCrossEncoder) + assert encoder.base_url == "https://rerank.example" + assert encoder._client.rerank_url == "https://rerank.example/v1/models/rerank" + + +@pytest.mark.asyncio +async def test_embedding_utils_routes_query_embeddings_to_provider_hook(): + from hindsight_api.engine.retain.embedding_utils import generate_embeddings_batch + + class QueryAwareEmbeddings: + def encode(self, texts: list[str]) -> list[list[float]]: + raise AssertionError("query embeddings should not use the generic encode method") + + def encode_query(self, texts: list[str]) -> list[list[float]]: + return [[float(len(text))] for text in texts] + + vectors = await generate_embeddings_batch(QueryAwareEmbeddings(), ["query text"], input_type="query") + + assert vectors == [[10.0]] + + +@pytest.mark.asyncio +async def test_embedding_utils_routes_document_embeddings_to_provider_hook(): + from hindsight_api.engine.retain.embedding_utils import generate_embeddings_batch + + class DocumentAwareEmbeddings: + def encode(self, texts: list[str]) -> list[list[float]]: + raise AssertionError("document embeddings should not use the generic encode method") + + def encode_documents(self, texts: list[str]) -> list[list[float]]: + return [[float(len(text))] for text in texts] + + vectors = await generate_embeddings_batch(DocumentAwareEmbeddings(), ["document text"], input_type="document") + + assert vectors == [[13.0]] diff --git a/hindsight-docs/docs/developer/configuration.md b/hindsight-docs/docs/developer/configuration.md index 785a480910..294e4ace0c 100644 --- a/hindsight-docs/docs/developer/configuration.md +++ b/hindsight-docs/docs/developer/configuration.md @@ -454,7 +454,7 @@ two slots that retain/consolidation cannot consume. | Variable | Description | Default | |----------|-------------|---------| -| `HINDSIGHT_API_EMBEDDINGS_PROVIDER` | Provider: `local`, `tei`, `openai`, `openai-codex`, `openrouter`, `cohere`, `google`, `litellm`, or `litellm-sdk` | `local` | +| `HINDSIGHT_API_EMBEDDINGS_PROVIDER` | Provider: `local`, `tei`, `openai`, `openai-codex`, `openrouter`, `cohere`, `google`, `zeroentropy`, `litellm`, or `litellm-sdk` | `local` | | `HINDSIGHT_API_EMBEDDINGS_LOCAL_MODEL` | Model for local provider | `BAAI/bge-small-en-v1.5` | | `HINDSIGHT_API_EMBEDDINGS_LOCAL_TRUST_REMOTE_CODE` | Allow loading models with custom code (security risk, disabled by default) | `false` | | `HINDSIGHT_API_EMBEDDINGS_LOCAL_FORCE_CPU` | Force CPU mode for local embeddings (avoids MPS/XPC issues on macOS) | `false` | @@ -466,6 +466,13 @@ two slots that retain/consolidation cannot consume. | `HINDSIGHT_API_EMBEDDINGS_OPENAI_DIMENSIONS` | Optional requested output dimensions for OpenAI `text-embedding-3` models (e.g., `384` to match an existing pgvector schema) | - | | `HINDSIGHT_API_EMBEDDINGS_OPENROUTER_API_KEY` | OpenRouter API key for embeddings (falls back to `HINDSIGHT_API_OPENROUTER_API_KEY`, then `HINDSIGHT_API_LLM_API_KEY`) | - | | `HINDSIGHT_API_EMBEDDINGS_OPENROUTER_MODEL` | OpenRouter embedding model | `perplexity/pplx-embed-v1-0.6b` | +| `HINDSIGHT_API_EMBEDDINGS_ZEROENTROPY_API_KEY` | ZeroEntropy API key for embeddings | - | +| `HINDSIGHT_API_EMBEDDINGS_ZEROENTROPY_MODEL` | ZeroEntropy embedding model | `zembed-1` | +| `HINDSIGHT_API_EMBEDDINGS_ZEROENTROPY_BASE_URL` | Custom base URL for ZeroEntropy-compatible API | `https://api.zeroentropy.dev` | +| `HINDSIGHT_API_EMBEDDINGS_ZEROENTROPY_DIMENSIONS` | Output dimensions for `zembed-1`. Supported values: `2560`, `1280`, `640`, `320`, `160`, `80`, `40` | `1280` | +| `HINDSIGHT_API_EMBEDDINGS_ZEROENTROPY_ENCODING_FORMAT` | Response encoding: `float` or `base64`. Hindsight decodes either format to float vectors before storage. | `float` | +| `HINDSIGHT_API_EMBEDDINGS_ZEROENTROPY_BATCH_SIZE` | Max inputs per ZeroEntropy embed request | `100` | +| `HINDSIGHT_API_EMBEDDINGS_ZEROENTROPY_LATENCY` | Optional latency mode: `fast` or `slow`. Leave unset to use ZeroEntropy's default routing. | - | | `HINDSIGHT_API_EMBEDDINGS_COHERE_API_KEY` | Cohere API key for embeddings | - | | `HINDSIGHT_API_EMBEDDINGS_COHERE_MODEL` | Cohere embedding model | `embed-english-v3.0` | | `HINDSIGHT_API_EMBEDDINGS_COHERE_BASE_URL` | Custom base URL for Cohere-compatible API (e.g., Azure-hosted) | - | @@ -486,6 +493,8 @@ two slots that retain/consolidation cannot consume. | `HINDSIGHT_API_EMBEDDINGS_VERTEXAI_REGION` | Vertex AI region for embeddings (falls back to `HINDSIGHT_API_LLM_VERTEXAI_REGION`) | - | | `HINDSIGHT_API_EMBEDDINGS_VERTEXAI_SERVICE_ACCOUNT_KEY` | Service account key for Vertex AI embeddings (falls back to `HINDSIGHT_API_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY`) | - | +Embedding provider selection, credentials, base URLs, model choices, dimensions, encoding format, batch sizes, and latency modes are static server-level settings. They are not hierarchical per-bank overrides. + #### Common Pitfall: Provider-Specific Embedding Env Var Names Embedding environment variables include a provider segment in the key name: @@ -546,6 +555,14 @@ export HINDSIGHT_API_EMBEDDINGS_PROVIDER=openrouter export HINDSIGHT_API_EMBEDDINGS_OPENROUTER_API_KEY=your-openrouter-api-key # or reuses HINDSIGHT_API_LLM_API_KEY export HINDSIGHT_API_EMBEDDINGS_OPENROUTER_MODEL=perplexity/pplx-embed-v1-0.6b +# ZeroEntropy - zembed-1 +export HINDSIGHT_API_EMBEDDINGS_PROVIDER=zeroentropy +export HINDSIGHT_API_EMBEDDINGS_ZEROENTROPY_API_KEY=your-api-key +export HINDSIGHT_API_EMBEDDINGS_ZEROENTROPY_MODEL=zembed-1 +export HINDSIGHT_API_EMBEDDINGS_ZEROENTROPY_DIMENSIONS=1280 +# export HINDSIGHT_API_EMBEDDINGS_ZEROENTROPY_ENCODING_FORMAT=base64 # optional +# export HINDSIGHT_API_EMBEDDINGS_ZEROENTROPY_LATENCY=fast # optional + # Cohere - cloud-based embeddings export HINDSIGHT_API_EMBEDDINGS_PROVIDER=cohere export HINDSIGHT_API_EMBEDDINGS_COHERE_API_KEY=your-api-key @@ -599,6 +616,8 @@ Hindsight automatically detects the embedding dimension from the model at startu For `litellm-sdk`, if you set `HINDSIGHT_API_EMBEDDINGS_LITELLM_SDK_OUTPUT_DIMENSIONS`, startup uses that output size when the underlying provider supports LiteLLM's `dimensions` parameter (otherwise behavior is unchanged). The same dimension-change rules below apply. +For `zeroentropy`, zembed-1 supports `2560`, `1280`, `640`, `320`, `160`, `80`, and `40` dimensions. ZeroEntropy's API default is `2560`; Hindsight defaults to `1280` so the provider works with the default pgvector HNSW index. Use `2560` with a vector extension that supports higher-dimensional indexes, such as DiskANN/pgvectorscale or ScaNN. + :::warning Dimension Changes Once memories are stored, you cannot change the embedding dimension without losing data. If you need to switch to a model with different dimensions: @@ -611,6 +630,8 @@ Supported OpenAI embedding dimensions: - `text-embedding-ada-002`: 1536 dimensions (legacy) Google's `gemini-embedding-001` produces 3072 dimensions natively but supports configurable output dimensionality. Set `HINDSIGHT_API_EMBEDDINGS_GEMINI_OUTPUT_DIMENSIONALITY` to control the output size (default: 768). + +ZeroEntropy's `zembed-1` supports Matryoshka dimensions: `2560`, `1280`, `640`, `320`, `160`, `80`, and `40`. Hindsight defaults to `1280` for this provider. ::: ### Reranker diff --git a/hindsight-docs/docs/developer/models.mdx b/hindsight-docs/docs/developer/models.mdx index 3ac76b84b0..771b797b50 100644 --- a/hindsight-docs/docs/developer/models.mdx +++ b/hindsight-docs/docs/developer/models.mdx @@ -397,6 +397,7 @@ Converts text into dense vector representations for semantic similarity search. | `cohere` | Cohere embeddings API | Production, multilingual | | `google` | Google embeddings (Gemini API or Vertex AI) | Production, multilingual, high quality | | `tei` | HuggingFace Text Embeddings Inference | Production, self-hosted | +| `zeroentropy` | ZeroEntropy zembed-1 | Production, high quality retrieval | | `litellm` | LiteLLM proxy (unified gateway) | Multi-provider setups | | `litellm-sdk` | LiteLLM SDK (direct API, no proxy) | Multi-provider, simpler setup | @@ -430,6 +431,14 @@ Google's `gemini-embedding-001` supports configurable output dimensionality via | `embed-english-v3.0` | 1024 | English text | | `embed-multilingual-v3.0` | 1024 | 100+ languages | +### ZeroEntropy Models + +| Model | Dimensions | Use Case | +|-------|------------|----------| +| `zembed-1` | 1280 default (2560/1280/640/320/160/80/40 configurable) | High quality asymmetric retrieval | + +Hindsight sends retained memory text to ZeroEntropy as `document` inputs and recall/search text as `query` inputs. ZeroEntropy's API default is 2560 dimensions; Hindsight defaults to 1280 so pgvector HNSW works without changing the vector extension. + :::warning Embedding Dimensions Hindsight automatically detects the embedding dimension at startup and adjusts the database schema. Once memories are stored, you cannot change dimensions without losing data. ::: @@ -465,6 +474,12 @@ export HINDSIGHT_API_EMBEDDINGS_VERTEXAI_PROJECT_ID=your-gcp-project-id export HINDSIGHT_API_EMBEDDINGS_PROVIDER=tei export HINDSIGHT_API_EMBEDDINGS_TEI_URL=http://localhost:8080 +# ZeroEntropy +export HINDSIGHT_API_EMBEDDINGS_PROVIDER=zeroentropy +export HINDSIGHT_API_EMBEDDINGS_ZEROENTROPY_API_KEY=your-api-key +export HINDSIGHT_API_EMBEDDINGS_ZEROENTROPY_MODEL=zembed-1 +export HINDSIGHT_API_EMBEDDINGS_ZEROENTROPY_DIMENSIONS=1280 + # LiteLLM proxy export HINDSIGHT_API_EMBEDDINGS_PROVIDER=litellm export HINDSIGHT_API_LITELLM_API_BASE=http://localhost:4000 diff --git a/skills/hindsight-docs/references/developer/configuration.md b/skills/hindsight-docs/references/developer/configuration.md index 089f5f14ca..206bc96cef 100644 --- a/skills/hindsight-docs/references/developer/configuration.md +++ b/skills/hindsight-docs/references/developer/configuration.md @@ -454,7 +454,7 @@ two slots that retain/consolidation cannot consume. | Variable | Description | Default | |----------|-------------|---------| -| `HINDSIGHT_API_EMBEDDINGS_PROVIDER` | Provider: `local`, `tei`, `openai`, `openai-codex`, `openrouter`, `cohere`, `google`, `litellm`, or `litellm-sdk` | `local` | +| `HINDSIGHT_API_EMBEDDINGS_PROVIDER` | Provider: `local`, `tei`, `openai`, `openai-codex`, `openrouter`, `cohere`, `google`, `zeroentropy`, `litellm`, or `litellm-sdk` | `local` | | `HINDSIGHT_API_EMBEDDINGS_LOCAL_MODEL` | Model for local provider | `BAAI/bge-small-en-v1.5` | | `HINDSIGHT_API_EMBEDDINGS_LOCAL_TRUST_REMOTE_CODE` | Allow loading models with custom code (security risk, disabled by default) | `false` | | `HINDSIGHT_API_EMBEDDINGS_LOCAL_FORCE_CPU` | Force CPU mode for local embeddings (avoids MPS/XPC issues on macOS) | `false` | @@ -470,6 +470,13 @@ two slots that retain/consolidation cannot consume. | `HINDSIGHT_API_EMBEDDINGS_COHERE_MODEL` | Cohere embedding model | `embed-english-v3.0` | | `HINDSIGHT_API_EMBEDDINGS_COHERE_BASE_URL` | Custom base URL for Cohere-compatible API (e.g., Azure-hosted) | - | | `HINDSIGHT_API_EMBEDDINGS_COHERE_OUTPUT_DIMENSIONS` | Output embedding dimensions for Cohere (e.g., `256`, `512`, `1024`). When set, overrides the model's default dimension. | - | +| `HINDSIGHT_API_EMBEDDINGS_ZEROENTROPY_API_KEY` | ZeroEntropy API key for zembed-1 embeddings | - | +| `HINDSIGHT_API_EMBEDDINGS_ZEROENTROPY_MODEL` | ZeroEntropy embedding model | `zembed-1` | +| `HINDSIGHT_API_EMBEDDINGS_ZEROENTROPY_BASE_URL` | ZeroEntropy API base URL. Use `https://eu-api.zeroentropy.dev` for EU routing. | `https://api.zeroentropy.dev` | +| `HINDSIGHT_API_EMBEDDINGS_ZEROENTROPY_DIMENSIONS` | zembed-1 output dimensions: `2560`, `1280`, `640`, `320`, `160`, `80`, or `40`. Hindsight defaults to `1280` to stay under pgvector HNSW's 2000-dimension limit. | `1280` | +| `HINDSIGHT_API_EMBEDDINGS_ZEROENTROPY_ENCODING_FORMAT` | Response encoding: `float` or `base64`. Hindsight decodes either format to float vectors before storage. | `float` | +| `HINDSIGHT_API_EMBEDDINGS_ZEROENTROPY_LATENCY` | Optional ZeroEntropy latency mode: `fast` or `slow`. Unset lets ZeroEntropy auto-select. | - | +| `HINDSIGHT_API_EMBEDDINGS_ZEROENTROPY_BATCH_SIZE` | Max inputs per ZeroEntropy `/v1/models/embed` call | `100` | | `HINDSIGHT_API_EMBEDDINGS_LITELLM_API_BASE` | LiteLLM proxy base URL for embeddings | `http://localhost:4000` | | `HINDSIGHT_API_EMBEDDINGS_LITELLM_API_KEY` | LiteLLM proxy API key for embeddings (optional, depends on proxy config) | - | | `HINDSIGHT_API_EMBEDDINGS_LITELLM_MODEL` | LiteLLM embedding model (use provider prefix, e.g., `cohere/embed-english-v3.0`) | `text-embedding-3-small` | @@ -486,6 +493,8 @@ two slots that retain/consolidation cannot consume. | `HINDSIGHT_API_EMBEDDINGS_VERTEXAI_REGION` | Vertex AI region for embeddings (falls back to `HINDSIGHT_API_LLM_VERTEXAI_REGION`) | - | | `HINDSIGHT_API_EMBEDDINGS_VERTEXAI_SERVICE_ACCOUNT_KEY` | Service account key for Vertex AI embeddings (falls back to `HINDSIGHT_API_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY`) | - | +Embedding provider selection, credentials, base URLs, model choices, dimensions, encoding format, batch sizes, and latency modes are static server-level settings. They are not hierarchical per-bank overrides. + #### Common Pitfall: Provider-Specific Embedding Env Var Names Embedding environment variables include a provider segment in the key name: @@ -559,6 +568,14 @@ export HINDSIGHT_API_EMBEDDINGS_COHERE_API_KEY=your-azure-api-key export HINDSIGHT_API_EMBEDDINGS_COHERE_MODEL=embed-english-v3.0 export HINDSIGHT_API_EMBEDDINGS_COHERE_BASE_URL=https://your-azure-cohere-endpoint.com +# ZeroEntropy - zembed-1 embeddings +export HINDSIGHT_API_EMBEDDINGS_PROVIDER=zeroentropy +export HINDSIGHT_API_EMBEDDINGS_ZEROENTROPY_API_KEY=your-zeroentropy-api-key +export HINDSIGHT_API_EMBEDDINGS_ZEROENTROPY_MODEL=zembed-1 +export HINDSIGHT_API_EMBEDDINGS_ZEROENTROPY_DIMENSIONS=1280 # supported: 2560, 1280, 640, 320, 160, 80, 40 +# export HINDSIGHT_API_EMBEDDINGS_ZEROENTROPY_ENCODING_FORMAT=base64 # optional +# export HINDSIGHT_API_EMBEDDINGS_ZEROENTROPY_LATENCY=fast # or slow; unset lets ZeroEntropy auto-select + # LiteLLM proxy - unified gateway for multiple providers export HINDSIGHT_API_EMBEDDINGS_PROVIDER=litellm export HINDSIGHT_API_EMBEDDINGS_LITELLM_API_BASE=http://localhost:4000 @@ -599,6 +616,8 @@ Hindsight automatically detects the embedding dimension from the model at startu For `litellm-sdk`, if you set `HINDSIGHT_API_EMBEDDINGS_LITELLM_SDK_OUTPUT_DIMENSIONS`, startup uses that output size when the underlying provider supports LiteLLM's `dimensions` parameter (otherwise behavior is unchanged). The same dimension-change rules below apply. +For `zeroentropy`, zembed-1 supports `2560`, `1280`, `640`, `320`, `160`, `80`, and `40` dimensions. ZeroEntropy's API default is `2560`; Hindsight defaults to `1280` so the provider works with the default pgvector HNSW index. Use `2560` with a vector extension that supports higher-dimensional indexes, such as DiskANN/pgvectorscale or ScaNN. + :::warning Dimension Changes Once memories are stored, you cannot change the embedding dimension without losing data. If you need to switch to a model with different dimensions: diff --git a/skills/hindsight-docs/references/developer/models.md b/skills/hindsight-docs/references/developer/models.md index 4254b7aba0..50199edbdd 100644 --- a/skills/hindsight-docs/references/developer/models.md +++ b/skills/hindsight-docs/references/developer/models.md @@ -423,6 +423,7 @@ Converts text into dense vector representations for semantic similarity search. | `openai` | OpenAI embeddings API | Production, high quality | | `cohere` | Cohere embeddings API | Production, multilingual | | `google` | Google embeddings (Gemini API or Vertex AI) | Production, multilingual, high quality | +| `zeroentropy` | ZeroEntropy zembed-1 embeddings API | Production, high-recall retrieval | | `tei` | HuggingFace Text Embeddings Inference | Production, self-hosted | | `litellm` | LiteLLM proxy (unified gateway) | Multi-provider setups | | `litellm-sdk` | LiteLLM SDK (direct API, no proxy) | Multi-provider, simpler setup | @@ -457,6 +458,14 @@ Google's `gemini-embedding-001` supports configurable output dimensionality via | `embed-english-v3.0` | 1024 | English text | | `embed-multilingual-v3.0` | 1024 | 100+ languages | +### ZeroEntropy Models + +| Model | Dimensions | Use Case | +|-------|------------|----------| +| `zembed-1` | 1280 default in Hindsight; supports 2560, 1280, 640, 320, 160, 80, 40 | High-recall multilingual retrieval | + +Hindsight uses ZeroEntropy's document embedding mode for retained content and query embedding mode for recall/search queries. ZeroEntropy's API default is 2560 dimensions; Hindsight defaults to 1280 so pgvector HNSW works without changing the vector extension. + > **⚠️ Embedding Dimensions** > Hindsight automatically detects the embedding dimension at startup and adjusts the database schema. Once memories are stored, you cannot change dimensions without losing data. @@ -477,6 +486,12 @@ export HINDSIGHT_API_EMBEDDINGS_PROVIDER=cohere export HINDSIGHT_API_COHERE_API_KEY=your-api-key export HINDSIGHT_API_EMBEDDINGS_COHERE_MODEL=embed-english-v3.0 +# ZeroEntropy +export HINDSIGHT_API_EMBEDDINGS_PROVIDER=zeroentropy +export HINDSIGHT_API_EMBEDDINGS_ZEROENTROPY_API_KEY=xxxxxxxxxxxx +export HINDSIGHT_API_EMBEDDINGS_ZEROENTROPY_MODEL=zembed-1 +export HINDSIGHT_API_EMBEDDINGS_ZEROENTROPY_DIMENSIONS=1280 + # Google (API key auth) export HINDSIGHT_API_EMBEDDINGS_PROVIDER=google export HINDSIGHT_API_EMBEDDINGS_GEMINI_API_KEY=xxxxxxxxxxxx