# Embedding Providers

> Documentation index: [llms.min.txt](https://caseyrfsmith.github.io/scull/llms.min.txt) · Document manifest: [documents.json](https://caseyrfsmith.github.io/scull/documents.json)

Breadcrumbs: Reference > Embedding Providers

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

```python
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.

```python
from scull.embedding import VoyageEmbed

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

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

Customization:

```python
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.

```python
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:

```bash
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:

```bash
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:

```bash
uv sync --extra local
```

```python
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.

```bash
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`:

```bash
# 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:

```python
# 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.

```python
@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:

```python
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
```
