Semantic Search in a Diary App: How NaturalLanguage Embeddings Find Meaning, Not Words#
Working on Lanternly — a diary app I currently have in development — I quickly ran into a limitation of ordinary search. A user writes an entry like "can't fall asleep, the same thought keeps looping in my head," then a month later searches for "anxiety" — and doesn't find that entry, because the word "anxiety" is never literally there. contains() and full-text indexes match strings, not meaning. For a diary to actually "remember" a person, search needs to understand that "can't fall asleep, the same thought keeps looping" and "anxiety before bed" are about the same experience. The fix turned out to be much closer than a cloud embeddings API: the NaturalLanguage framework, which Apple has been building into iOS for years, already turns text into a meaning vector — right on the device, with zero network requests.
Why a Diary Needs "Search by Meaning" When Cmd+F Already Exists#
A keyword rarely matches how a person actually recalls an event. A diary isn't a tagged log — it's a stream of free-form text, and the queries against it are free-form too: "when was the last time I got angry at my mom," "entries about burnout at work," "days when money felt okay." None of those entries may contain a single word from the query, yet the meaning lines up.
Full-text search (an NSPredicate with CONTAINS[cd], or FTS5 in Core Data/SQLite) is excellent at a different job — exact matching of terms, names, dates. These are different tools for different questions, and semantic search doesn't replace full-text search; it complements it wherever a user is searching for a feeling, not a string.
That's exactly where the idea of an embedding comes in: turn text into a point in a high-dimensional space so that texts close in meaning end up near each other, and unrelated ones end up far apart. From there, search becomes pure geometry — find the points closest to the query point.
NLEmbedding vs NLContextualEmbedding: What Apple Ships Out of the Box#
NaturalLanguage offers two fundamentally different ways to get a vector.
NLEmbedding — static word and sentence embeddings (NLEmbedding.wordEmbedding(for:), NLEmbedding.sentenceEmbedding(for:)). The model already ships with the OS, vectors compute instantly, but the same word always maps to the same vector regardless of context — "key" as in "house key" and "key" as in "the key to the puzzle" get identical representations.
NLContextualEmbedding (iOS 17+) — transformer-based embeddings (BERT-like). A token's vector takes neighboring words into account, so ambiguity and nuance carry through much more accurately. The trade-off: the model is significantly heavier, and its assets need a one-time download to the device via requestAssets(completionHandler:) before you can call load().
For a diary, where the nuances of a person's state actually matter, I picked NLContextualEmbedding as the primary path — keeping NLEmbedding as a fast fallback for languages without a contextual model.
| Criterion | NLEmbedding (static) | NLContextualEmbedding | External vector DB (Pinecone / pgvector / Weaviate) |
|---|---|---|---|
| Model type | Static, context-independent | Transformer (BERT-like), context-aware | Any external embedding model of choice |
| Where it runs | On-device, built into the OS | On-device, assets download on demand | Server/cloud |
| Infrastructure | None | None | DB server, API keys, billing |
| Works offline | Yes | Yes, after the first asset download | No, requires a network call |
| Privacy | Data never leaves the device | Data never leaves the device | Text is sent to a third-party server |
| Languages | Limited set | ~27 languages, grouped by script (WWDC23+) | Any — depends on the model |
| Nuance accuracy | Lower, doesn't disambiguate polysemy | Higher, considers sentence context | Highest, SOTA + domain fine-tuning |
| Data scale | Thousands of entries, linear scan | Thousands of entries, linear scan | Millions+, ANN indexes (HNSW, etc.) |
| Best for | MVPs, quick prototypes | A user's personal data: diary, notes, mail | General content, many users, huge volumes |
Getting a Sentence Embedding On-Device#
Here's the minimal working path: create a contextual model for a language, download its assets if needed, load the model, and get a single vector for a whole sentence by pooling its token vectors.
import NaturalLanguage
/// Loads a contextual (transformer-based) embedding model for a language,
/// downloading the on-device assets on first use if they are not cached yet.
func makeContextualEmbedding(for language: NLLanguage) throws -> NLContextualEmbedding? {
guard let embedding = NLContextualEmbedding(language: language) else {
return nil // No contextual model ships for this language/script
}
if !embedding.hasAvailableAssets {
let group = DispatchGroup()
group.enter()
embedding.requestAssets { _, _ in group.leave() }
group.wait()
}
try embedding.load()
return embedding
}
/// Produces one fixed-length vector for a whole sentence by mean-pooling
/// the subword token vectors that NLContextualEmbedding returns.
func sentenceVector(
for text: String,
embedding: NLContextualEmbedding,
language: NLLanguage
) throws -> [Double] {
let result = try embedding.embeddingResult(for: text, language: language)
var sum = [Double](repeating: 0, count: embedding.dimension)
var tokenCount = 0
result.enumerateTokenVectors(in: text.startIndex..<text.endIndex) { vector, _ in
for i in 0..<vector.count { sum[i] += vector[i] }
tokenCount += 1
return true // keep iterating
}
guard tokenCount > 0 else { return sum }
return sum.map { $0 / Double(tokenCount) }
}NLContextualEmbeddingResult returns a vector per subword token, not one vector per sentence — a deliberate choice on Apple's part, since token-level vectors are also needed for finer-grained tasks like NER or token classification. For searching diary entries, averaging (mean pooling) is enough — a simple, predictable way to get one vector per whole entry.
Cosine Similarity: Comparing Two Vectors#
Once both texts are represented as vectors of the same dimensionality, "are these similar in meaning" turns into "what's the angle between these vectors." Cosine similarity is independent of text length — a short entry and a long entry about the same topic will still end up close together.
/// Cosine similarity between two vectors.
/// 1.0 = same direction (same meaning), 0.0 = unrelated, -1.0 = opposite.
func cosineSimilarity(_ a: [Double], _ b: [Double]) -> Double {
guard a.count == b.count, !a.isEmpty else { return 0 }
var dot = 0.0
var normA = 0.0
var normB = 0.0
for i in 0..<a.count {
dot += a[i] * b[i]
normA += a[i] * a[i]
normB += b[i] * b[i]
}
guard normA > 0, normB > 0 else { return 0 }
return dot / (normA.squareRoot() * normB.squareRoot())
}NLEmbedding itself can compute distance out of the box — distance(between:and:distanceType:) with NLDistanceType.cosine. But for NLContextualEmbedding, where you assemble the sentence vector yourself via pooling, you need your own cosine similarity implementation — which is exactly why the function above is generic over any [Double] array.
Indexing Entries: Compute Embeddings Once#
The most common mistake in a first implementation is recomputing the query embedding and every entry's embedding on every single search. An entry's vector only changes when the entry's text changes, so it should be computed once — on save — and stored alongside the text.
/// A diary entry paired with its precomputed semantic vector.
struct IndexedEntry {
let id: UUID
let text: String
let vector: [Double]
}
/// Builds an in-memory semantic index for a set of entries.
/// In a real app this runs once per entry, on save — never on every search.
final class SemanticEntryIndex {
private var embedding: NLContextualEmbedding?
private let language: NLLanguage
private(set) var entries: [IndexedEntry] = []
init(language: NLLanguage = .russian) {
self.language = language
}
func prepare() throws {
embedding = try makeContextualEmbedding(for: language)
}
func index(id: UUID, text: String) throws {
guard let embedding else { return }
let vector = try sentenceVector(for: text, embedding: embedding, language: language)
entries.append(IndexedEntry(id: id, text: text, vector: vector))
}
}In practice, it's convenient to store the entry vector in SwiftData/Core Data next to the text (as [Double], or packed into Data), rather than recomputing it on every app launch — recomputation is only needed when the user edits the entry's text.
Ranked Search: Finding Entries "About the Same Thing"#
The final step is turning the user's query into the same kind of vector, then sorting every indexed entry by descending cosine similarity to it.
/// Returns entries closest in meaning to `query`, best match first.
func semanticSearch(
query: String,
in index: SemanticEntryIndex,
embedding: NLContextualEmbedding,
language: NLLanguage,
limit: Int = 10
) throws -> [(entry: IndexedEntry, score: Double)] {
let queryVector = try sentenceVector(for: query, embedding: embedding, language: language)
return index.entries
.map { entry in (entry: entry, score: cosineSimilarity(queryVector, entry.vector)) }
.sorted { $0.score > $1.score }
.prefix(limit)
.map { $0 }
}For a diary with a few thousand entries, this linear scan takes milliseconds — approximate indexes (HNSW, IVF) only start to matter once the entry count grows much larger, which brings us to the ceiling of this approach.
Where the Ceiling Is: Languages, the 2026 RAG Trend, and When You Actually Need a Vector DB#
This approach has real boundaries, and being honest about them matters more than selling the idea as a silver bullet.
Languages. NLContextualEmbedding doesn't support every language — models are grouped by script family (Apple demonstrated this at WWDC23 alongside its multilingual, BERT-based Create ML models), and coverage of Latin, CJK, and other script groups isn't even. Check NLContextualEmbedding.languages for a given model before relying on it, and have a fallback path ready for languages that don't have a contextual model.
Scale. A linear scan over cosine similarity works great up to a few thousand entries — a typical size for a personal diary spanning years. For millions of entries and many users, you genuinely need approximate indexes and an external vector database (Pinecone, Weaviate, pgvector) — that's a different class of problem, with different cost and privacy trade-offs.
The RAG trend. In 2026, mobile ecosystems have shown a clear shift toward "local-first": apps increasingly keep the embedding model and vector search on-device, sending to the cloud only what genuinely requires shared knowledge or cross-user syncing. Personal assistant memory is exactly the case where on-device isn't a compromise — it's the more correct architecture: the data never leaves the phone, and search still works in airplane mode.
For Lanternly, this choice was obvious from the start: a diary is arguably the most private data a person ever writes. Sending it anywhere just to enable semantic search would betray the whole premise of the app. NLEmbedding and NLContextualEmbedding deliver semantic search without a single line of server code — and that's exactly the case where a constraint turns into the right architectural decision, not a compromise.



