Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 5 additions & 9 deletions hindsight-api-slim/hindsight_api/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -566,7 +566,8 @@ def normalize_config_dict(config: dict[str, Any]) -> dict[str, Any]:

# ZeroEntropy defaults
DEFAULT_EMBEDDINGS_ZEROENTROPY_MODEL = "zembed-1"
DEFAULT_EMBEDDINGS_ZEROENTROPY_BASE_URL = "https://api.zeroentropy.dev"
# Shared between embeddings (zembed-1) and reranker (zerank-*) — the host is the same.
DEFAULT_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
Expand Down Expand Up @@ -1261,7 +1262,7 @@ class HindsightConfig:
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_base_url: str = DEFAULT_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
Expand Down Expand Up @@ -1468,11 +1469,6 @@ 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.
Expand Down Expand Up @@ -1675,7 +1671,7 @@ def from_env(cls) -> "HindsightConfig":
ENV_EMBEDDINGS_ZEROENTROPY_MODEL, DEFAULT_EMBEDDINGS_ZEROENTROPY_MODEL
),
embeddings_zeroentropy_base_url=os.getenv(
ENV_EMBEDDINGS_ZEROENTROPY_BASE_URL, DEFAULT_EMBEDDINGS_ZEROENTROPY_BASE_URL
ENV_EMBEDDINGS_ZEROENTROPY_BASE_URL, DEFAULT_ZEROENTROPY_BASE_URL
),
embeddings_zeroentropy_dimensions=_parse_positive_int(
ENV_EMBEDDINGS_ZEROENTROPY_DIMENSIONS,
Expand All @@ -1684,7 +1680,7 @@ def from_env(cls) -> "HindsightConfig":
),
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,
os.getenv(ENV_EMBEDDINGS_ZEROENTROPY_ENCODING_FORMAT),
frozenset({"float", "base64"}),
)
or DEFAULT_EMBEDDINGS_ZEROENTROPY_ENCODING_FORMAT,
Expand Down
3 changes: 2 additions & 1 deletion hindsight-api-slim/hindsight_api/engine/cross_encoder.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
DEFAULT_RERANKER_TEI_HTTP_TIMEOUT,
DEFAULT_RERANKER_TEI_MAX_CONCURRENT,
DEFAULT_RERANKER_ZEROENTROPY_MODEL,
DEFAULT_ZEROENTROPY_BASE_URL,
ENV_RERANKER_ALIBABA_API_KEY,
ENV_RERANKER_COHERE_API_KEY,
ENV_RERANKER_COHERE_MODEL,
Expand Down Expand Up @@ -773,7 +774,7 @@ class ZeroEntropyCrossEncoder(CrossEncoderModel):
See: https://docs.zeroentropy.dev/models
"""

DEFAULT_BASE_URL = "https://api.zeroentropy.dev"
DEFAULT_BASE_URL = DEFAULT_ZEROENTROPY_BASE_URL
RERANK_PATH = "/v1/models/rerank"

def __init__(
Expand Down
28 changes: 7 additions & 21 deletions hindsight-api-slim/hindsight_api/engine/embeddings.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,13 @@
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,
DEFAULT_ZEROENTROPY_BASE_URL,
ENV_EMBEDDINGS_COHERE_API_KEY,
ENV_EMBEDDINGS_GEMINI_API_KEY,
ENV_EMBEDDINGS_LOCAL_FORCE_CPU,
Expand Down Expand Up @@ -79,14 +79,8 @@ 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):
Expand Down Expand Up @@ -760,14 +754,14 @@ class ZeroEntropyEmbeddings(Embeddings):
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
DEFAULT_BASE_URL = DEFAULT_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,
base_url: str | None = None,
dimensions: int = DEFAULT_EMBEDDINGS_ZEROENTROPY_DIMENSIONS,
batch_size: int = DEFAULT_EMBEDDINGS_ZEROENTROPY_BATCH_SIZE,
encoding_format: str = DEFAULT_EMBEDDINGS_ZEROENTROPY_ENCODING_FORMAT,
Expand All @@ -790,7 +784,8 @@ def __init__(

self.api_key = api_key
self.model = model
self.base_url = base_url.rstrip("/")
self.base_url = base_url.rstrip("/") if base_url else self.DEFAULT_BASE_URL
self.embed_url = f"{self.base_url}{self.EMBED_PATH}"
self.dimensions = dimensions
self.batch_size = batch_size
self.encoding_format = cast(ZeroEntropyEncodingFormat, encoding_format)
Expand Down Expand Up @@ -850,8 +845,6 @@ def _encode_with_input_type(self, texts: list[str], input_type: ZeroEntropyInput
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(
Expand All @@ -864,7 +857,7 @@ def _encode_with_input_type(self, texts: list[str], input_type: ZeroEntropyInput
)

try:
response = self._client.post(embed_url, json=request.model_dump(exclude_none=True))
response = self._client.post(self.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
Expand All @@ -879,13 +872,6 @@ def _encode_with_input_type(self, texts: list[str], input_type: ZeroEntropyInput

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):
Expand Down Expand Up @@ -1439,7 +1425,7 @@ def create_embeddings_from_env() -> Embeddings:
return ZeroEntropyEmbeddings(
api_key=api_key,
model=config.embeddings_zeroentropy_model,
base_url=config.embeddings_zeroentropy_base_url or DEFAULT_EMBEDDINGS_ZEROENTROPY_BASE_URL,
base_url=config.embeddings_zeroentropy_base_url,
dimensions=config.embeddings_zeroentropy_dimensions,
batch_size=config.embeddings_zeroentropy_batch_size,
encoding_format=config.embeddings_zeroentropy_encoding_format,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@


class EmbeddingsBackend(Protocol):
def encode(self, texts: list[str]) -> list[list[float]]: ...
"""Minimal duck-typed surface used by retain/recall — the concrete `Embeddings`
ABC supplies default implementations that delegate to `encode()`."""

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

Expand Down Expand Up @@ -43,13 +44,9 @@ def generate_embedding(
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):
if input_type == "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)
return embeddings_backend.encode_documents(texts)


async def generate_embeddings_batch(
Expand Down
6 changes: 3 additions & 3 deletions hindsight-api-slim/tests/test_retain_orchestrator_mapping.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,21 +96,21 @@ def test_raises_when_backend_returns_fewer_embeddings(self):
# through — `zip(extracted_facts, embeddings)` would otherwise drop
# facts and break unit_id alignment downstream.
backend = MagicMock()
backend.encode.return_value = [[0.1, 0.2]] # only 1 vector for 3 inputs
backend.encode_documents.return_value = [[0.1, 0.2]] # only 1 vector for 3 inputs

with pytest.raises(RuntimeError, match="returned 1 vectors for 3 input texts"):
asyncio.run(embedding_utils.generate_embeddings_batch(backend, ["a", "b", "c"]))

def test_raises_when_backend_returns_more_embeddings(self):
backend = MagicMock()
backend.encode.return_value = [[0.1], [0.2], [0.3]]
backend.encode_documents.return_value = [[0.1], [0.2], [0.3]]

with pytest.raises(RuntimeError, match="returned 3 vectors for 2 input texts"):
asyncio.run(embedding_utils.generate_embeddings_batch(backend, ["a", "b"]))

def test_passes_through_aligned_embeddings(self):
backend = MagicMock()
backend.encode.return_value = [[0.1], [0.2]]
backend.encode_documents.return_value = [[0.1], [0.2]]

result = asyncio.run(embedding_utils.generate_embeddings_batch(backend, ["a", "b"]))

Expand Down
23 changes: 23 additions & 0 deletions hindsight-api-slim/tests/test_zeroentropy_embeddings.py
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,29 @@ def handler(request: httpx.Request) -> httpx.Response:
assert all(request.latency == "fast" for request in requests)


def test_zeroentropy_omits_latency_when_unset():
import json

from hindsight_api.engine.embeddings import ZeroEntropyEmbeddings

seen_bodies: list[dict] = []

def handler(request: httpx.Request) -> httpx.Response:
seen_bodies.append(json.loads(request.content))
return httpx.Response(200, json={"results": [{"embedding": [1.0, 2.0]}]})

embeddings = ZeroEntropyEmbeddings(api_key="ze-test", dimensions=1280, latency=None)
embeddings._client = httpx.Client(
transport=httpx.MockTransport(handler),
headers={"Authorization": "Bearer ze-test", "Content-Type": "application/json"},
)
embeddings._dimension = 1280

embeddings.encode_documents(["alpha"])

assert "latency" not in seen_bodies[0]


def test_zeroentropy_encode_query_sends_query_input_type():
from hindsight_api.engine.embeddings import ZeroEntropyEmbeddings

Expand Down
89 changes: 89 additions & 0 deletions hindsight-api-slim/tests/test_zeroentropy_live.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
"""Live ZeroEntropy API integration tests for both embeddings (zembed-1) and the reranker (zerank-2).

These tests hit the real ZeroEntropy API. They are skipped unless ZEROENTROPY_LIVE_API_KEY
is set, so CI and local default runs are unaffected. Set the env var to exercise the
full request/response path against the production endpoint.
"""

import os

import pytest

LIVE_API_KEY = os.environ.get("ZEROENTROPY_LIVE_API_KEY")
SKIP_REASON = "ZEROENTROPY_LIVE_API_KEY not set - skipping live ZeroEntropy integration test"


@pytest.mark.skipif(not LIVE_API_KEY, reason=SKIP_REASON)
@pytest.mark.asyncio
async def test_live_zeroentropy_embeddings_document_and_query():
"""zembed-1 returns 1280-dim float vectors for both document and query input types,
and the two input types yield distinct vectors for the same text (asymmetric encoder)."""
from hindsight_api.engine.embeddings import ZeroEntropyEmbeddings

assert LIVE_API_KEY is not None
embeddings = ZeroEntropyEmbeddings(api_key=LIVE_API_KEY, dimensions=1280)
await embeddings.initialize()

docs = embeddings.encode_documents(["Paris is the capital of France.", "Python is a programming language."])
assert len(docs) == 2
assert all(len(v) == 1280 for v in docs)
assert all(isinstance(x, float) for x in docs[0])

queries = embeddings.encode_query(["What is the capital of France?"])
assert len(queries) == 1
assert len(queries[0]) == 1280

# Asymmetric encoder: same text embedded as document vs query should differ.
same_text = "Paris is the capital of France."
doc_vec = embeddings.encode_documents([same_text])[0]
query_vec = embeddings.encode_query([same_text])[0]
assert doc_vec != query_vec


@pytest.mark.skipif(not LIVE_API_KEY, reason=SKIP_REASON)
@pytest.mark.asyncio
async def test_live_zeroentropy_embeddings_base64_matches_float():
"""The base64 response encoding decodes to the same vectors (within float tolerance)
as the float response encoding."""
from hindsight_api.engine.embeddings import ZeroEntropyEmbeddings

assert LIVE_API_KEY is not None
text = "ZeroEntropy supports Matryoshka embeddings."

float_provider = ZeroEntropyEmbeddings(api_key=LIVE_API_KEY, dimensions=640, encoding_format="float")
await float_provider.initialize()
float_vec = float_provider.encode_documents([text])[0]

base64_provider = ZeroEntropyEmbeddings(api_key=LIVE_API_KEY, dimensions=640, encoding_format="base64")
await base64_provider.initialize()
base64_vec = base64_provider.encode_documents([text])[0]

assert len(float_vec) == 640
assert len(base64_vec) == 640
# Same text + same dimensions through different transport encodings should match within float32 precision.
assert all(abs(a - b) < 1e-5 for a, b in zip(float_vec, base64_vec, strict=True))


@pytest.mark.skipif(not LIVE_API_KEY, reason=SKIP_REASON)
@pytest.mark.asyncio
async def test_live_zeroentropy_reranker_orders_by_relevance():
"""zerank-2 returns higher scores for more relevant passages."""
from hindsight_api.engine.cross_encoder import ZeroEntropyCrossEncoder

assert LIVE_API_KEY is not None
encoder = ZeroEntropyCrossEncoder(api_key=LIVE_API_KEY)
await encoder.initialize()

query = "What is the capital of France?"
pairs = [
(query, "Paris is the capital and most populous city of France."),
(query, "Python is a high-level programming language."),
(query, "The Pacific Ocean is the largest body of water on Earth."),
]

scores = await encoder.predict(pairs)

assert len(scores) == 3
# Relevant passage should outrank the unrelated ones.
assert scores[0] > scores[1]
assert scores[0] > scores[2]
Loading