Table of Contents
- The problem
- Design decisions and alternatives
- A real bug: redaction that leaked look-alike text
- Usage
- Limits
The problem
Apps hold text that users want to search or ask about: notes, help articles, saved messages.
Apple ships most of the pieces to do this on the device. NaturalLanguage
tokenizes and embeds text, Accelerate multiplies matrices quickly, and from iOS 26 and macOS 26
Foundation Models gives access to an on-device language model. What is missing is the layer
that joins them: take a document, split it into passages, remove personal data, embed it, index
it, search it, hand the best passages to a model with instructions to cite them, and save the
whole thing between launches.
SwiftIntelligence 2.0 is that layer, and only that layer. It is one library target with no
dependencies, for iOS 18 and macOS 15. It does not train models, process images or audio, or
act as a server-side vector database.
The narrow scope comes from a rewrite. The repository opened in August 2025 as a broad
"AI/ML framework". By version 1.2.2 it had grown to 16 modules and, in the words of the
2.0.0 changelog, "about 50,000 lines, most of which returned simulated results (random
confidences, fixed sentences, provider stubs)". In June 2026 a cleanup commit
(`32a1ca7`) removed
release scripts and unused modules but also added a path dependency on a sibling folder,
../SwiftAI, so the manifest no longer resolved on any other machine. On 14 October 2026
the first commit of the rewrite
(`0e2dcbd`) deleted the
old modules, and PR #9 merged
the result as v2.0.0 on 15 October. No 1.x API was kept, which is why it is a major version.
Design decisions and alternatives
One target, no dependencies. The 1.x layout of many modules hid how little of the code did
real work. For about 2,400 lines of source, separate library products (core, search, privacy,
generation) would add maintenance without helping a user who just wants to add one package.
The code can still be split later if it grows.
KnowledgeBase is an actor. In the Swift 6 language mode an actor is the shortest way to
keep the mutable indexes free of data races. Actors are reentrant, though: another call can run
while one is suspended at an await. So add first computes and validates every embedding of
a batch, the only part that awaits, and then stores everything with no suspension point. The
source says it plainly: "No suspension point from here on: concurrent calls cannot interleave
their changes." An embedding error therefore leaves the knowledge base unchanged instead of
half-indexed. search follows the same rule: its one await is embedding the query.
A lock-guarded class would not be checked by the compiler, and @MainActor would run
searches on the UI thread.
Hybrid search with reciprocal rank fusion. Every passage is indexed twice: by its words with
BM25 in KeywordIndex, and as a vector in VectorIndex. Vectors match passages that use other
words; BM25 matches exact codes such as "E-4012" that a small embedding model can miss. Their
scores are on different scales, so a weighted sum would need an arbitrary normalization. RankFusion uses only positions instead: an
item's fused score is the sum of weight / (k + rank) over the lists, with
defaultSmoothing = 60.0, "the usual smoothing constant from Cormack, Clarke and Büttcher
(2009)". Ties keep the order in which items first appear, so results are deterministic.
Exact search, no approximate index. VectorIndex stores unit-length rows in one flat array,
so cosine similarity is a dot product, and a search is a single vDSP_mmul call over all rows.
The target is one user's or one app's documents, which the README puts at "up to tens of
thousands of passages". At that size exact search is simple and has no recall loss, while an
approximate index such as HNSW would add memory and complexity. The cost is that search time grows linearly with the number of vectors.
A hashing embedder as the default. HashingEmbedder hashes words and three-character pieces
into 512 dimensions. It needs no model, works in every language and on the iOS simulator, and
is deterministic in tests. It uses its own 64-bit FNV-1a, because, as the source comment
explains, "Swift's Hasher is seeded per process, so it cannot be used for vectors that are
saved and compared later." SentenceEmbedder and ContextualEmbedder wrap Apple's embeddings,
and any type conforming to TextEmbedder can be plugged in. The embedder's identifier is saved
with the data, and loading with a different one throws KnowledgeBaseError.embedderMismatch
rather than comparing vectors that are not comparable.
Grounded answers that may refuse. GroundedPrompt numbers the passages and tells the model
to answer only from them and to cite them as [1]. The passage budget defaults to 6,000
characters. When nothing matches, answer(_:) throws AnswerError.noRelevantPassages
without calling the model at all. The answer code sits behind canImport(FoundationModels) and
availability checks, so the package still builds for iOS 18 and macOS 15.
A real bug: redaction that leaked look-alike text
PersonalDataRedactor replaces email addresses, card numbers, IBANs and other personal data
with placeholders such as [EMAIL_1]. Its first version ran its patterns on the raw text, and
the tests used neatly formatted samples, so they passed. But a redactor's job is precisely the
text that is not neatly formatted.
Commit `3e96601`
records what a systematic try found: "Against the previous code, 10 of 12 sample values leaked
completely": card numbers grouped with en dashes, dots or a line break, a card after another
digit group ("Order 12 4111 1111 1111 1111"), full-width and accented email addresses, IBANs
followed directly by a code, and IPv4 addresses in full-width digits. Overlapping matches were
resolved by dropping the shorter one, which left its non-overlapping part visible.
The fix changed where matching happens. Patterns now run on a folded copy of the text
(FoldedText): format characters removed, NFKC compatibility forms, combining marks stripped,
every dash turned into "-" and every space into " ". A map of the changed characters takes each
match back to the original text. Card candidates are runs of digit groups, and inside each run
the longest window that passes the Luhn check wins. IBANs are cut to their country's length and
checked with mod 97. Overlapping matches are merged. Later commits closed more variants of the
same class, and the RedactionBypassTests suite keeps all of them, including look-alikes that
must stay visible, such as numbers that fail the checksum.
A second fix shows a related lesson. VectorMath.normalized divided a vector
by its largest value with vDSP, and Accelerate flushes subnormal numbers to zero, so a finite
vector like [1e-39, 0, 0, 0] became [inf, nan, nan, nan]. Adding and saving succeeded,
but loading rejected the saved vectors, and the knowledge base could no longer be opened.
Commit `aabec82`
scales such vectors by 2^64 in plain Swift first, and a test now adds, saves, loads and
searches a document whose embedding is subnormal. The lesson: check an invariant when the
data is written and again when it is read, and test the round trip.
Usage
Add https://github.com/muhittincamdali/SwiftIntelligence.git with Swift Package Manager
from 2.0.0, then add documents and search them. This sample is copied from the README, where
it is compiled by the example app and run by the package tests through ReadmeSamples.swift:
1let knowledge = KnowledgeBase()2try await knowledge.add("You can return any item within 30 days of delivery for a full refund.", id: "returns")3try await knowledge.add("Orders ship within two business days; delivery takes three to five days.", id: "shipping")4try await knowledge.add("Every laptop comes with a two-year warranty against defects.", id: "warranty")5 6let results = try await knowledge.search("How do I return an item for a refund?")7print(results.first?.documentID ?? "nothing found") // returns8print(results.first?.text ?? "") // You can return any item within 30 days …Redaction is reversible, so a model can work with placeholders and the app can put the values
back into its answer:
1let redactor = PersonalDataRedactor()2let redacted = redactor.redact("Mail jane@example.com or pay with 4111 1111 1111 1111.")3print(redacted.text) // "Mail [EMAIL_1] or pay with [CARD_1]."4 5// After a model has answered from the redacted text, put the values back.6let reply = redacted.restoringOriginals(in: "I wrote to [EMAIL_1].")7print(reply) // "I wrote to jane@example.com."At tag v2.0.0, swift test on macOS runs 156 tests in 22 suites; the README reports 155 on an
iPhone simulator, where tests that need missing NaturalLanguage models are skipped with a reason.
Limits
- Scale. Indexes are exact and in memory. They suit one user's or one app's documents, up to
tens of thousands of passages. The 2.0.0 release publishes no benchmark.
- Original text is not saved. A saved knowledge base holds the redacted passages and their
vectors. Switching embedders means adding the documents again.
- Redaction is a safety net, not a guarantee. Data that is spelled out ("jane at example dot
com"), split by ordinary words, or invalid by its checksum is not found, and name detection is
statistical.
- Semantic quality depends on Apple's models. They are small general models, and the iOS
simulator does not have them. The README recommends keeping hybrid mode unless your own tests
show semantic search alone is better.
- Answers need iOS 26 or macOS 26 on a device with Apple Intelligence turned on, and a
model can follow instructions written inside documents; SECURITY.md lists this as a known
limit.
- Saved files are not encrypted by the package. SECURITY.md suggests
.completeFileProtection when saving.
The README roadmap lists two next steps: PDFs and images (PDFKit and Vision text recognition)
as document sources, and a Core ML sentence-encoder embedder for stronger semantic search.
Source code: github.com/muhittincamdali/SwiftIntelligence
Tags
iOS Development News
Weekly Swift tips, SwiftUI tricks and iOS best practices. No spam, only valuable content.
Your subscription starts when you open the link in the confirmation email and press “Confirm my subscription”. The newsletter keeps open/click statistics; you can unsubscribe anytime with one click. Privacy

