Skip to content
Open
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
33 changes: 33 additions & 0 deletions docs/user_guide/04_vectorizers.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -596,6 +596,39 @@
"print(test[:10])"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"#### Contextualized embeddings\n",
"\n",
"VoyageAI's `voyage-context-*` models support *contextualized* chunk embeddings. The `VoyageAIVectorizer` automatically routes `voyage-context-*` models to the contextualized embeddings API: pass your list of chunk strings to `embed_many` and you get one embedding back per chunk. Each input string is sent as its own auto-chunked document, so the inputs are embedded **independently** (a chunk's embedding does not depend on the other strings in the list). This preserves the one-embedding-per-input contract and keeps the embeddings cache deterministic."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# Contextualized embeddings (voyage-context-* models). Each input string is sent\n",
"# as its own auto-chunked document and embedded independently, so the vectorizer\n",
"# returns exactly one embedding per input string. Requires voyageai>=0.5.0.\n",
"context_vo = VoyageAIVectorizer(\n",
" model=\"voyage-context-4\", # See https://docs.voyageai.com/docs/contextualized-chunk-embeddings\n",
" api_config={\"api_key\": api_key},\n",
")\n",
"\n",
"chunks = [\n",
" \"That is a happy dog\",\n",
" \"That is a happy person\",\n",
" \"Today is a sunny day\",\n",
"]\n",
"context_embeddings = context_vo.embed_many(chunks, input_type=\"document\")\n",
"print(\"Number of embeddings:\", len(context_embeddings))\n",
"print(\"Vector dimensions:\", len(context_embeddings[0]))"
]
},
{
"cell_type": "markdown",
"metadata": {},
Expand Down
2 changes: 1 addition & 1 deletion docs/user_guide/06_rerankers.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -378,7 +378,7 @@
"source": [
"from redisvl.utils.rerank import VoyageAIReranker\n",
"\n",
"reranker = VoyageAIReranker(model=\"rerank-lite-1\", limit=3, api_config={\"api_key\": api_key})\n",
"reranker = VoyageAIReranker(model=\"rerank-2.5\", limit=3, api_config={\"api_key\": api_key})\n",
"# Please check the available models at https://docs.voyageai.com/docs/reranker"
]
},
Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ mcp = [
mistralai = ["mistralai>=1.0.0,<2"]
openai = ["openai>=1.1.0"]
cohere = ["cohere>=4.44"]
voyageai = ["voyageai>=0.2.2"]
voyageai = ["voyageai>=0.5.0"]
# Floor is a security constraint, not an API one: below 5.2.0 pins transformers<5.0.0.
sentence-transformers = ["sentence-transformers>=5.2.0,<6"]
langcache = ["langcache>=0.11.0"]
Expand Down Expand Up @@ -78,7 +78,7 @@ all = [
"mistralai>=1.0.0,<2",
"openai>=1.1.0",
"cohere>=4.44",
"voyageai>=0.2.2",
"voyageai>=0.5.0",
"sentence-transformers>=5.2.0,<6",
"langcache>=0.11.0",
"google-cloud-aiplatform>=1.26,<2.0.0",
Expand Down
226 changes: 212 additions & 14 deletions redisvl/utils/vectorize/voyageai.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import os
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, cast

from pydantic import ConfigDict
from tenacity import retry, stop_after_attempt, wait_random_exponential
Expand All @@ -13,13 +13,47 @@
# ignore that voyageai isn't imported
# mypy: disable-error-code="name-defined"

# Per-request total token limits by model, used for token-aware batching of the
# plain text embedding path. Values track VoyageAI's documented per-request token
# limits (https://docs.voyageai.com/docs/embeddings). Unknown models fall back to
# the conservative default below.
VOYAGE_TOKEN_LIMITS: dict[str, int] = {
"voyage-2": 320_000,
"voyage-02": 320_000,
"voyage-3": 120_000,
"voyage-3-lite": 120_000,
"voyage-3-large": 120_000,
"voyage-3.5": 320_000,
"voyage-3.5-lite": 1_000_000,
"voyage-4": 320_000,
"voyage-4-lite": 1_000_000,
"voyage-4-large": 120_000,
"voyage-4-nano": 120_000,
"voyage-code-2": 120_000,
"voyage-code-3": 120_000,
"voyage-code-4": 120_000,
"voyage-finance-2": 120_000,
"voyage-law-2": 120_000,
"voyage-large-2": 120_000,
"voyage-large-2-instruct": 120_000,
"voyage-multilingual-2": 120_000,
}
DEFAULT_VOYAGE_TOKEN_LIMIT = 120_000


class VoyageAIVectorizer(BaseVectorizer):
"""The VoyageAIVectorizer class utilizes VoyageAI's API to generate
embeddings for text and multimodal (text / image / video) data.

This vectorizer is designed to interact with VoyageAI's /embed and /multimodal_embed APIs,
requiring an API key for authentication. The key can be provided
This vectorizer is designed to interact with VoyageAI's /embed, /multimodal_embed,
and /contextualized_embed APIs. Any model identifier accepted by VoyageAI can be
passed via ``model`` - for example the general-purpose ``voyage-4-large`` /
``voyage-4`` / ``voyage-4-lite`` / ``voyage-4-nano`` family, domain models such
as ``voyage-code-4``, contextualized ``voyage-context-4`` / ``voyage-context-3``
models, and multimodal ``voyage-multimodal-*`` models.
See https://docs.voyageai.com/docs/embeddings for the current catalog.

It requires an API key for authentication. The key can be provided
directly in the `api_config` dictionary or through the `VOYAGE_API_KEY`
environment variable. User must obtain an API key from VoyageAI's website
(https://dash.voyageai.com/). Additionally, the `voyageai` python
Expand Down Expand Up @@ -50,6 +84,25 @@ class VoyageAIVectorizer(BaseVectorizer):
input_type="document"
)

# Contextualized embeddings (voyage-context-* models) - requires voyageai>=0.5.0
# Each input string is treated as its own document (auto-chunked) and
# embedded independently: inputs do not influence one another, which keeps
# the one-embedding-per-input contract and cache determinism intact.
context_vectorizer = VoyageAIVectorizer(
model="voyage-context-4",
api_config={"api_key": "your-voyageai-api-key"}
)
context_embeddings = context_vectorizer.embed_many(
contents=["chunk one", "chunk two", "chunk three"],
input_type="document"
)
# Retrieval queries use input_type="query"; auto-chunking is a
# document-only feature, so query inputs are embedded as-is.
context_query = context_vectorizer.embed(
content="your query text here",
input_type="query"
)

# Multimodal usage - requires Pillow and voyageai>=0.3.6

vectorizer = VoyageAIVectorizer(
Expand Down Expand Up @@ -120,6 +173,17 @@ def __init__(
Notes:
- Multimodal models require voyageai>=0.3.6 to be installed for video embeddings, as well as
ffmpeg installed on the system. Image embeddings require pillow to be installed.
- Contextualized (``voyage-context-*``) models require voyageai>=0.5.0. Each input
string is sent as its own document with auto-chunking, so inputs are embedded
independently (no cross-input contextualization) and the one-embedding-per-input
contract and cache determinism are preserved. A document longer than ``chunk_size``
(32000 tokens) auto-chunks into multiple chunks but only the first chunk's embedding
is kept; the rest are dropped. ``truncation`` is not forwarded to the contextualized
API (it does not accept it), so it is silently ignored for these models.
- The plain text embedding path (``embed``/``embed_many`` for non-context,
non-multimodal models) uses token-aware batching: inputs are grouped into requests
bounded by both the per-model item cap and the model's per-request token limit, so
large inputs are packed efficiently without exceeding VoyageAI's token budget.

"""
super().__init__(model=model, dtype=dtype, cache=cache)
Expand All @@ -131,6 +195,11 @@ def is_multimodal(self) -> bool:
"""Whether a multimodal model has been configured."""
return "multimodal" in self.model

@property
def is_context(self) -> bool:
"""Whether a contextualized-embedding model (voyage-context-*) has been configured."""
return "context" in self.model

def embed_image(self, image_path: str, **kwargs) -> list[float] | bytes:
"""Embed an image (from its path on disk) using VoyageAI's multimodal API. Requires pillow to be installed."""
if not self.is_multimodal:
Expand Down Expand Up @@ -242,19 +311,56 @@ def _set_model_dims(self) -> int:

def _get_batch_size(self) -> int:
"""
Determine the appropriate batch size based on the model being used.
Determine the per-request item cap for the current model.

For the plain text path this is combined with the model's per-request
token limit (see :meth:`_batchify_by_tokens`); it is the sole bound for
the context/multimodal paths.

Returns:
int: Recommended batch size for the current model
int: Recommended maximum number of items per request
"""
if self.model in ["voyage-2", "voyage-02"]:
return 72
elif self.model in ["voyage-3-lite", "voyage-3.5-lite"]:
elif self.model in ["voyage-3-lite", "voyage-3.5-lite", "voyage-4-lite"]:
return 30
elif self.model in ["voyage-3", "voyage-3.5"]:
elif self.model in ["voyage-3", "voyage-3.5", "voyage-4"]:
return 10
else:
return 7 # Default for other models
# Default for other models (e.g. voyage-3-large, voyage-4-large,
# voyage-code-*, voyage-finance-2, voyage-law-2, voyage-context-*).
return 7

def _token_limit(self) -> int:
"""Per-request token budget for the current model (token-aware batching)."""
return VOYAGE_TOKEN_LIMITS.get(self.model, DEFAULT_VOYAGE_TOKEN_LIMIT)

def _count_tokens(self, text: str) -> int:
"""Count tokens for a single text using VoyageAI's tokenizer."""
return len(self._client.tokenize([text], model=self.model)[0])

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tokenize required for text embeds

Medium Severity

Plain-text embedding now calls Client.tokenize for every input via _batchify_by_tokens, including the single-string dimension probe in _set_model_dims. Voyage loads that tokenizer from Hugging Face for voyageai/{model}, so init and embed_many fail if the Hub is unreachable or no tokenizer exists for the model id, even when the Voyage embed API itself would succeed.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 7024c58. Configure here.


def _batchify_by_tokens(self, texts: list[str], batch_size: int):
"""Yield batches of texts bounded by both item count and token budget.

Batches are grown until adding the next text would exceed either the
per-model item cap (``batch_size``) or the model's per-request token limit.
A single text larger than the token limit is still yielded on its own
(never dropped), matching VoyageAI's own client batching behavior.
"""
max_tokens = self._token_limit()
batch: list[str] = []
batch_tokens = 0
for text in texts:
n_tokens = self._count_tokens(text)
if batch and (
len(batch) >= batch_size or batch_tokens + n_tokens > max_tokens
):
yield batch
batch, batch_tokens = [], 0
batch.append(text)
batch_tokens += n_tokens
if batch:
yield batch

def _validate_input(
self, contents: list[Any], input_type: str | None, truncation: bool | None
Expand Down Expand Up @@ -340,13 +446,29 @@ def _embed_many(
if batch_size is None:
batch_size = self._get_batch_size()

# The plain text path uses token-aware batching; context/multimodal paths
# stay on fixed item-count batching (auto-chunking / opaque media inputs).
if self.is_context or self.is_multimodal:
batches: Any = self.batchify(contents, batch_size)
else:
batches = self._batchify_by_tokens(contents, batch_size)

try:
embeddings: list[Any] = []
for batch in self.batchify(contents, batch_size):
for batch in batches:
if self.is_context:
embeddings.extend(
self._embed_context_batch(batch, input_type, **kwargs)
)
continue
response = self._embed_fn(
(
[batch] if self.is_multimodal else batch
), # Multimodal requires a list of lists/dicts
# Multimodal wraps each item as its own single-part input
# so one embedding is returned per requested content.
[[item] for item in batch]
if self.is_multimodal
else batch
),
model=self.model,
input_type=input_type,
truncation=truncation,
Expand All @@ -359,6 +481,66 @@ def _embed_many(
except Exception as e:
raise ValueError(f"Embedding texts failed: {e}")

def _context_embed_kwargs(
self, batch: list[str], input_type: str | None, kwargs: dict[str, Any]
) -> dict[str, Any]:
"""Build the kwargs for a ``contextualized_embed`` call.

The batch is passed as a flat ``list[str]``. Auto-chunking is only valid
for ``input_type="document"`` (VoyageAI rejects it for queries), so every
non-query input - including the default where ``input_type`` is omitted -
is treated as a document and chunked with a large ``chunk_size``, making
each input resolve to a single chunk. Explicit queries skip chunking and
are sent as a flat ``list[str]`` as-is. Either way the first chunk per
input is kept, yielding one embedding per requested item.
"""
enable_auto_chunking = input_type != "query"
# Auto-chunking requires input_type="document"; a flat list[str] with no
# type is rejected, so default (None) callers - e.g. SemanticRouter and
# SemanticCache, which never set input_type - resolve to "document".
effective_input_type = "document" if enable_auto_chunking else input_type
call_kwargs: dict[str, Any] = {
"inputs": batch,
"model": self.model,
"input_type": effective_input_type,
"enable_auto_chunking": enable_auto_chunking,
**kwargs,
}
if enable_auto_chunking:
call_kwargs["chunk_size"] = 32000
return call_kwargs
Comment thread
cursor[bot] marked this conversation as resolved.

def _embed_context_batch(
self, batch: list[str], input_type: str | None, **kwargs
) -> list[list[float]]:
"""Embed a batch with a contextualized (voyage-context-*) model."""
# contextualized_embed is only present on recent voyageai clients; the
# attr-defined ignore keeps mypy happy without vendored stubs.
response = self._client.contextualized_embed( # type: ignore[attr-defined]
**self._context_embed_kwargs(batch, input_type, kwargs),
)
# Take the first chunk per input to keep one embedding per input.
return cast(
"list[list[float]]",
[result.embeddings[0] for result in response.results],
)

async def _aembed_context_batch(
self, batch: list[str], input_type: str | None, **kwargs
) -> list[list[float]]:
"""Asynchronously embed a batch with a contextualized model.

See :meth:`_embed_context_batch` for details on the input format.
"""
response = await self._aclient.contextualized_embed( # type: ignore[attr-defined]
**self._context_embed_kwargs(batch, input_type, kwargs),
)
# Take the first chunk per input to keep one embedding per input.
return cast(
"list[list[float]]",
[result.embeddings[0] for result in response.results],
)

async def _aembed(self, content: Any, **kwargs) -> list[float]:
"""
Asynchronously generate a vector embedding for a single item using the VoyageAI API.
Expand Down Expand Up @@ -415,13 +597,29 @@ async def _aembed_many(
if batch_size is None:
batch_size = self._get_batch_size()

# The plain text path uses token-aware batching; context/multimodal paths
# stay on fixed item-count batching (auto-chunking / opaque media inputs).
if self.is_context or self.is_multimodal:
batches: Any = self.batchify(contents, batch_size)
else:
batches = self._batchify_by_tokens(contents, batch_size)

try:
embeddings: list[Any] = []
for batch in self.batchify(contents, batch_size):
for batch in batches:
if self.is_context:
embeddings.extend(
await self._aembed_context_batch(batch, input_type, **kwargs)
)
continue
response = await self._aembed_fn(
(
[batch] if self.is_multimodal else batch
), # Multimodal requires a list of lists/dicts
# Multimodal wraps each item as its own single-part input
# so one embedding is returned per requested content.
[[item] for item in batch]
if self.is_multimodal
else batch
),
model=self.model,
input_type=input_type,
truncation=truncation,
Expand Down
Loading