On-device RAG (retrieval-augmented generation) consumer layer for a documentation-corpus search app, written in Swift. SearchKit owns everything domain-specific — catalog ingestion, chunking, embedding, hybrid retrieval, deterministic recall and prompt construction — on top of SQLiteVecKit's SQLiteVecStore, which provides the SQLite vector + FTS5 storage.
Everything runs locally: embeddings come from Apple's NLContextualEmbedding (Natural Language framework), storage is a single SQLite file, and the final prompt is handed to whatever LLM the caller chooses. No network calls are made by this package.
- Swift 6.0 toolchain (strict concurrency)
- iOS 17+ / macOS 14+
ContextualEmbeddingProvider(the real embedding backend) does not work on the iOS Simulator — use macOS or a physical device. Tests cover this with a deterministic fake.
Add SearchKit to your Package.swift:
dependencies: [
.package(url: "https://github.com/carlosypunto/SearchKit", exact: "0.2.0")
]SearchKit itself depends on SQLiteVecKit (pinned to 0.1.1), pulled transitively via SPM.
import SearchKit
// 1. Embedding stack: real provider + pipeline (indexing and query vectors
// are guaranteed to go through identical steps).
let provider = try ContextualEmbeddingProvider(languageCode: "es")
let pipeline = EmbeddingPipeline(provider: provider)
let manifest = await pipeline.makeManifest() // model, dimension, pooling, metric, mean-centering…
// 2. Index store: a single SQLite file, treated as a regenerable cache.
// Any manifest mismatch (model change, dimension change…) wipes it.
let dbURL = URL.applicationSupportDirectory.appending(path: "search-index.sqlite")
let indexStore = try await SearchIndexStore(dbURL: dbURL, manifest: manifest)
// 3. Orchestrator.
let service = SearchService(indexStore: indexStore, pipeline: pipeline)
// 4. Ingest the catalog (Markdown files with front matter, bundled as resources).
let catalog = BundleCatalogRepository(bundle: .main)
let documents = try await catalog.documents()
let summary = try await service.sync(documents: documents)
print("indexed \(summary.indexed), removed \(summary.removed), unchanged \(summary.unchanged)")
// 5. Query. `.auto` mode degrades gracefully (hybrid → vector-only → text-only).
let outcome = try await service.search(
"How work strict concurrency?",
options: SearchOptions(mode: .auto, topK: 8, filter: SearchFilter(language: "es"))
)
// 6. Build a grounded RAG prompt and forward it to your LLM of choice.
let prompt = PromptBuilder().prompt(
question: "How work strict concurrency?",
candidates: outcome.candidates,
language: .spanish
)Indexing: CatalogRepository → ChunkingService → EmbeddingPipeline → SearchIndexStore
Querying: SearchService → SearchIndexStore → Reranker → DeterministicRecallPolicy → PromptBuilder
Each stage is a separate, independently testable type. SearchService is the only orchestrator that wires them together — start there to see the whole flow. The Reranker protocol (default: NoOpReranker) is an extension point for post-retrieval reordering of the fused candidate list — e.g. an on-device cross-encoder; it runs before deterministic recall and the final filter re-validation, so it can never suppress an exact-title injection or resurrect filtered-out candidates. Design invariants (score scales, filter re-validation, manifest invalidation) are documented in AGENTS.md and in the DocC catalog (Sources/SearchKit/Documentation.docc).
The rendered API documentation is published at
carlosypunto.github.io/SearchKit
on every push to main (the same workflow gates PRs with docc --warnings-as-errors).
| Mode | Retrieval | On failure |
|---|---|---|
.auto |
Hybrid (RRF of vector KNN + BM25) | Degrades: → vector-only → text-only |
.hybrid |
Hybrid | Throws (no fallback) |
.vector |
Vector KNN only | Throws |
.text |
FTS5/BM25 only | Throws |
SearchOutcome.mode reports the mode actually used, so callers can detect an .auto degradation. Scores are always "higher = better" but their scale differs per mode — never compare scores across modes.
SearchKitExample/ is a SwiftUI demo (iOS 17+ / macOS 14+) that runs the
whole pipeline against a bundled bilingual corpus of ~100 Markdown documents: hybrid, vector
and text modes, family/language filters, distance metric switching (watch the index rebuild)
and a RAG prompt preview per result. Open the Xcode project and run the macOS scheme —
real embeddings do not work on the iOS Simulator.
Tests use Swift Testing (@Suite, @Test, #expect), not XCTest.
swift build
swift test
# Exclude the two opt-in/slow suites from a normal run
swift test --skip Benchmark --skip RealEmbedding
# Real NLContextualEmbedding integration test — needs model assets on-device;
# never works on the iOS Simulator, only macOS or a real device
SEARCHKIT_REAL_EMBEDDING=1 swift test --filter RealEmbedding
# Retrieval-quality evaluation (real model + the example-app corpus; macOS or
# a real device). Results log: docs/retrieval-quality.md
SEARCHKIT_EVAL=1 swift test --filter EvaluationMIT — see LICENSE.