For agents: Skip to article. Documentation index. Read this page as Markdown.
Scull
Reference

Embedding Providers

Choose embedding providers and keep query models compatible with indexes.

Read as Markdown

Swappable abstraction for vector embeddings. Operators can switch between Voyage AI, OpenAI, local ONNX, or custom providers without modifying core logic anywhere else.

Quick Start

from scull.embedding import get_provider

# Auto-selects based on SCULL_EMBED_PROVIDER env var (defaults to "voyage")
provider = get_provider()

vectors = provider.embed(["some text"], input_type="document")
# Returns numpy array of shape (1, 1024) for voyage-4-lite

Available Providers

VoyageEmbed (default)

Voyage AI's voyage-4-lite model. Fast, cheap, well-suited for code/API domain text.

from scull.embedding import VoyageEmbed

# Uses VOYAGE_API_KEY env var if api_key=None
provider = VoyageEmbed()

vectors = provider.embed(["integer division behavior"])

Customization:

provider = VoyageEmbed(
    api_key="your-key",      # defaults to VOYAGE_API_KEY env var
    model="voyage-3-lite"     # or any voyageai-supported model
)

OpenAIEmbed

OpenAI embeddings via the openai Python SDK.

from scull.embedding import OpenAIEmbed

# Uses OPENAI_API_KEY env var if api_key=None
provider = OpenAIEmbed()

vectors = provider.embed(["query text"])

Note: OpenAI doesn't support input_type. The parameter is accepted but ignored.

VoyageNanoLocal (explicit offline option)

Voyage 4 Nano is an optional local query provider for indexes built with the compatible Voyage 4 family. It is never selected automatically and is not part of the core or [search] installation:

uv sync --extra voyage-nano
export SCULL_EMBED_PROVIDER=voyage-nano
export SCULL_EMBED_DIMENSION=1024
scull search "your question"

The implementation pins model revision 67fabc9bef010dabc5f6024aa1b1b6b93410426f, enables the model repository's remote code, and uses Sentence Transformers. Operators must explicitly accept that Hugging Face remote-code trust boundary. The measured model cache is approximately 672 MiB; observed cold initialization was about two seconds and warm uncached query embedding about 34–35 ms on the evaluation machine. Those timings are observations, not hardware-independent guarantees.

After deliberately populating the cache, an offline process can prevent Hub access:

export HF_HUB_OFFLINE=1
export TRANSFORMERS_OFFLINE=1
export SCULL_EMBED_PROVIDER=voyage-nano
scull search "your question"

LocalEmbed

Opt-in local embeddings powered by FastEmbed's quantized ONNX runtime. MiniLM-L6-v2 is the default, selected after a fixed 100-query quality benchmark. Install the separate extra first:

uv sync --extra local
from scull.embedding import LocalEmbed

provider = LocalEmbed()
vectors = provider.embed(["integer division behavior"], input_type="query")

The model is downloaded on first use and cached by FastEmbed. After that, local query embedding needs no API key or network access. MiniLM uses a 0.1 lexical weight; hosted providers retain 0.3, based on the retrieval benchmark documented in the repository.

Provider/index safety

A schemaset is plain YAML; its generated index contains the document vectors. Every published index records the exact provider class, model name, and vector dimension used to create those vectors. Before embedding a query, Scull requires the configured query provider to match that identity. Unknown or misspelled provider names also fail closed; they never fall back to a hosted provider.

The reviewed Voyage 4 family is the sole cross-model exception: voyage-4-large, voyage-4, voyage-4-lite, and pinned local voyage-4-nano may query one another's indexes when dimensions match. voyage-code-4 is deliberately excluded. All unknown families retain exact-identity matching. Selecting local against a Voyage index therefore fails before query embedding; Scull never compares MiniLM and Voyage vectors.

export SCULL_EMBED_PROVIDER=local
scull-schemafier kg build --force

Configuration

Environment Variable Purpose Default
SCULL_EMBED_PROVIDER Provider (voyage, voyage-nano, openai, or local) "voyage"
VOYAGE_API_KEY Required for voyage provider —
OPENAI_API_KEY Required for openai provider —
SCULL_EMBED_MODEL Model name override (e.g. "text-embedding-3-small") Provider default
SCULL_EMBED_CACHE_DIR Local model download/cache directory FastEmbed default
SCULL_EMBED_DIMENSION Voyage Nano output dimension 1024

Switch providers by setting SCULL_EMBED_PROVIDER:

# Use OpenAI instead of Voyage AI
export SCULL_EMBED_PROVIDER=openai
export OPENAI_API_KEY=sk-...

# Back to Voyage
export SCULL_EMBED_PROVIDER=voyage
export VOYAGE_API_KEY=pa-...

# Fully local; rebuild because this is a different vector space
uv sync --extra local
export SCULL_EMBED_PROVIDER=local
scull-schemafier kg build --force
scull search "how does integer division work?"

Or pass explicitly:

# Explicitly select a provider (overrides env vars)
provider = get_provider(provider_name="openai", api_key="sk-...")

Why Separate Implementations Instead of a Wrapper

Voyage AI and OpenAI both produce vector embeddings, but their SDKs expose different interfaces:

Feature Voyage AI OpenAI Local
Runtime Hosted API Hosted API FastEmbed/ONNX
API key Required Required None
input_type paths Supported Ignored Separate query/passage paths
Default lexical weight 0.3 0.3 0.1

A single "OpenAI-compatible" wrapper wouldn't work because:

  • Voyage has features OpenAI lacks. The input_type="query" vs input_type="document" distinction matters for embedding quality. Hiding it behind an adapter would make that capability unreachable without custom subclasses.
  • An adapter only adds indirection. You'd write code to convert Voyage → OpenAI's surface so you can use OpenAI's SDK, which defeats the purpose. Two implementations behind one interface (EmbedProvider) is simpler and more direct.
  • Future providers follow the same pattern. Adding Cohere or any other provider means writing one new class that implements EmbedProvider.embed() — no adapter layer required.

The current design: three classes, one shared contract. Callers never know which provider they're using. Switching is done via env vars or by passing provider_name= explicitly.

API Reference

class EmbedProvider(ABC)

Abstract base class defining the embedding interface.

@abstractmethod
def embed(self, texts: List[str], input_type: str = "document") -> np.ndarray:
    """
    Generate embeddings for a list of texts.

    Args:
        texts: List of text strings to embed
        input_type: "document" or "query" (provider-specific).
                    Some providers (OpenAI) ignore this parameter.

    Returns:
        numpy array of shape (len(texts), embedding_dim)
    """

get_provider(provider_name=None, **kwargs) -> EmbedProvider

Factory function that resolves which provider to use.

Parameters:

  • provider_name — Override provider name (voyage, voyage-nano, openai, or local). If None, uses SCULL_EMBED_PROVIDER or defaults to voyage.
  • **kwargs — Passed to the provider constructor (api_key, model, etc.)

Returns: An initialized EmbedProvider instance.

Testing

All providers work with mocks since they only produce numpy arrays:

from unittest.mock import patch
from scull.embedding import VoyageEmbed

with patch.object(VoyageEmbed, 'embed', return_value=[[0.1, 0.2, 0.3]]):
    vectors = VoyageEmbed().embed(["test"])
    assert len(vectors[0]) == 3