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"vsinput_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, orlocal). If None, usesSCULL_EMBED_PROVIDERor defaults tovoyage.**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