Text embeddings and reranking for Swift: two small protocols, multiple provider clients, pure HTTP. No FoundationModels dependency.
- One module.
import Embeddingsgives you the protocols, the transport, and every provider. - Direct constructors. Each model is a concrete public struct you build with an API key — no provider factory or registry.
- Dependency-light. Only swift-http-types.
- Swift 6, fully Sendable. Platforms floor: iOS 15 / macOS 12 / tvOS 15 / watchOS 8 / visionOS 1, plus Linux and Android.
.package(url: "https://github.com/tdurand/swift-embeddings.git", from: "0.1.0"),.target(name: "App", dependencies: [
.product(name: "Embeddings", package: "swift-embeddings"),
])import Embeddings
let model = VoyageEmbeddingModel(model: .voyage3, apiKey: key)
let embeddings = try await model.embed("how does Swift manage memory?")
print(embeddings.vectors) // [[Double]]Batch inputs and provider-specific options are supported:
let model = JinaEmbeddingModel(model: .embeddingsV3, apiKey: key)
let embeddings = try await model.embed(
["query one", "query two"],
options: .jina(task: .retrievalQuery, dimensions: 256)
)Reranking is a second-stage refinement for retrieval: fetch a broad candidate set cheaply (for example by embedding similarity), then score each candidate against the query for a sharper final ordering.
let reranker = CohereRerankModel(model: .rerankV35, apiKey: key)
let ranking = try await reranker.rerank(
"best fruit for a smoothie",
documents: ["apple pie", "ripe mango", "car engine"],
options: .init(topN: 2)
)
for ranked in ranking.results {
print(ranked.index, ranked.relevanceScore)
}On Apple platforms you can embed text entirely on-device — no API key and no
network request — using NaturalLanguageEmbeddingModel, backed by Apple's
NaturalLanguage framework.
let model = NaturalLanguageEmbeddingModel() // .automatic
let embeddings = try await model.embed("how does Swift manage memory?")
print(embeddings.vectors) // [[Double]]By default (.automatic) it uses the transformer-based NLContextualEmbedding
on iOS 17 / macOS 14 and later — mean-pooling its per-token vectors into one
vector per input — and falls back to NLEmbedding's sentence embeddings on
earlier systems or when no contextual asset is available for the input's
language. Use .contextual or .sentence to pin a backend. The language is
auto-detected per input; pass it explicitly to skip detection:
try await model.embed("bonjour le monde", options: .naturalLanguage(language: .french))The result's model reports the backend used (e.g. "contextual:en",
"sentence:fr"), and usage is always nil since there is no token billing
on-device.
| Provider | Embedding | Reranking |
|---|---|---|
| Voyage | ✅ VoyageEmbeddingModel |
✅ VoyageRerankModel |
| Jina | ✅ JinaEmbeddingModel |
✅ JinaRerankModel |
| Cohere | ✅ CohereEmbeddingModel |
✅ CohereRerankModel |
| OpenAI | ✅ OpenAIEmbeddingModel |
— |
| Apple NaturalLanguage (on-device) | ✅ NaturalLanguageEmbeddingModel |
— |
Each model takes a typed model identifier (e.g. .voyage3, with string-literal
fallback for unlisted models), an apiKey, and optionally a custom
transport and baseURL — handy for OpenAI-compatible gateways or tests.
Every model issues requests through an HTTPClientTransport. The default is
URLSessionTransport; conform your own type to plug in a different HTTP client
or a mock for offline tests:
struct MyTransport: HTTPClientTransport {
func execute(_ request: HTTPRequest, body: HTTPRequestBody?) async throws -> HTTPResponseData {
// ...
}
}
let model = OpenAIEmbeddingModel(
model: .textEmbedding3Small,
apiKey: key,
transport: MyTransport()
)MIT © 2026 Thomas Durand