Natural Language Framework: Text Analysis and NLP On-Device
Your app has a text field where users write movie reviews, and you need to figure out what language they are writing in, whether they mentioned a character by name, and how they feel about the film — all without a single network request. Apple’s Natural Language framework handles every one of those tasks on-device, with latency measured in microseconds.
This post covers the five core capabilities of Natural Language — language identification, tokenization, part-of-speech tagging, named entity recognition (NER), and sentiment analysis — with production-grade examples. We will not cover custom Core ML model integration (that is its own post on Core ML Integration) or the newer Foundation Models framework for generative tasks.
Contents
- The Problem
- Language Identification
- Tokenization
- Part-of-Speech Tagging
- Named Entity Recognition
- Sentiment Analysis
- Advanced Usage
- Performance Considerations
- When to Use (and When Not To)
- Summary
The Problem
Imagine you are building a Pixar movie review app. Users submit free-form text in any language, and you need to power several features: localized search requires you to know the input language, autocomplete needs word boundaries, accessibility features need grammatical structure, a “mentioned characters” sidebar depends on extracting proper nouns, and a review sentiment score drives the UI’s color scheme.
A naive approach might look like this:
func analyzeReview(_ text: String) -> ReviewAnalysis {
// Splitting on spaces breaks for Japanese, Chinese, Thai...
let words = text.components(separatedBy: .whitespaces)
// Regex for names? Good luck with Unicode and multilingual input.
let namePattern = /[A-Z][a-z]+/
let names = words.compactMap { word in
word.firstMatch(of: namePattern)?.output.flatMap(String.init)
}
// Keyword matching for sentiment is fragile and language-dependent.
let positiveKeywords = ["great", "love", "amazing", "beautiful"]
let score = words.filter {
positiveKeywords.contains($0.lowercased())
}.count
return ReviewAnalysis(
words: words, names: names, sentimentScore: score
)
}
This approach fails in every dimension. Space-splitting does not work for CJK languages. Regex-based name extraction misses “WALL-E” and “Remy.” Keyword sentiment is English-only and misses sarcasm, negation, and context. The Natural Language framework replaces all of this with linguistically aware, multilingual APIs.
Language Identification
NLLanguageRecognizer identifies the
dominant language of a string and can return probability distributions across multiple candidate languages. This is your
first step in any multilingual pipeline — you need to know the language before you can tokenize, tag, or analyze
sentiment correctly.
import NaturalLanguage
func identifyLanguage(for text: String) -> NLLanguage? {
let recognizer = NLLanguageRecognizer()
recognizer.processString(text)
return recognizer.dominantLanguage
}
let review = "Woody est un personnage incroyable dans Toy Story"
if let language = identifyLanguage(for: review) {
print("Detected language: \(language.rawValue)")
}
Detected language: fr
The recognizer returns an NLLanguage value — a
struct with a rawValue that maps to BCP 47 language tags like "en", "fr", or "ja". For short or ambiguous text,
use languageHypotheses(withMaximum:) to get a ranked dictionary of candidates with confidence scores:
func languageHypotheses(
for text: String,
maxCandidates: Int = 5
) -> [(NLLanguage, Double)] {
let recognizer = NLLanguageRecognizer()
recognizer.processString(text)
return recognizer
.languageHypotheses(withMaximum: maxCandidates)
.sorted { $0.value > $1.value }
.map { ($0.key, $0.value) }
}
let ambiguous = "Coco"
let hypotheses = languageHypotheses(for: ambiguous)
for (language, confidence) in hypotheses {
print("\(language.rawValue): \(String(format: "%.2f", confidence))")
}
Short strings like a single movie title produce lower confidence scores. If your app collects mixed-language content,
you can also set languageConstraints and languageHints to bias the recognizer toward expected languages.
Tip: If you already know the user’s locale preference, pass it as a hint via
recognizer.languageHintsto improve accuracy on short inputs. This is particularly useful for single-word queries in a search field.
Tokenization
NLTokenizer breaks text into linguistically
correct units — words, sentences, or paragraphs. Unlike String.components(separatedBy:), it handles CJK languages,
contractions, hyphenated compounds, and punctuation correctly.
import NaturalLanguage
func tokenize(
_ text: String,
unit: NLTokenUnit = .word
) -> [String] {
let tokenizer = NLTokenizer(unit: unit)
tokenizer.string = text
let range = text.startIndex..<text.endIndex
return tokenizer.tokens(for: range).map { tokenRange in
String(text[tokenRange])
}
}
let review = "WALL-E's animation is breathtaking, isn't it?"
let words = tokenize(review, unit: .word)
print(words)
["WALL-E's", "animation", "is", "breathtaking", "isn't", "it"]
Notice that NLTokenizer keeps "WALL-E's" as a single token and treats "isn't" as one unit rather than splitting on
the apostrophe. This is critical for downstream NLP tasks — splitting "isn't" into ["isn", "t"] would break
part-of-speech tagging.
You can set the language explicitly for more accurate tokenization:
let japaneseReview = "トイストーリーは素晴らしい映画です"
let tokenizer = NLTokenizer(unit: .word)
tokenizer.string = japaneseReview
tokenizer.setLanguage(.japanese)
let tokens = tokenizer.tokens(
for: japaneseReview.startIndex..<japaneseReview.endIndex
).map { String(japaneseReview[$0]) }
print(tokens)
["トイストーリー", "は", "素晴らしい", "映画", "です"]
Without setting the language, the tokenizer still works — it will attempt automatic detection — but setting it explicitly removes ambiguity and can improve speed.
Apple Docs:
NLTokenizer— Natural Language
Part-of-Speech Tagging
NLTagger assigns linguistic tags to each token
in a string. The .lexicalClass
scheme identifies nouns, verbs, adjectives, and other parts of speech. This is the foundation for extracting meaningful
structure from free-form text.
import NaturalLanguage
func tagPartsOfSpeech(in text: String) -> [(String, NLTag)] {
let tagger = NLTagger(tagSchemes: [.lexicalClass])
tagger.string = text
var results: [(String, NLTag)] = []
let range = text.startIndex..<text.endIndex
let options: NLTagger.Options = [
.omitWhitespace, .omitPunctuation
]
tagger.enumerateTags(
in: range,
unit: .word,
scheme: .lexicalClass,
options: options
) { tag, tokenRange in
if let tag {
results.append((String(text[tokenRange]), tag))
}
return true // Continue enumeration
}
return results
}
let review = "Remy cooks amazing ratatouille in the kitchen"
let tagged = tagPartsOfSpeech(in: review)
for (word, tag) in tagged {
let padded = word.padding(
toLength: 15, withPad: " ", startingAt: 0
)
print("\(padded) \(tag.rawValue)")
}
Remy Noun
cooks Verb
amazing Adjective
ratatouille Noun
in Preposition
the Determiner
kitchen Noun
The tagger’s closure-based enumerateTags(in:unit:scheme:options:) API gives you both the tag and the range of the
token. Return true from the closure to continue enumeration, or false to stop early — useful when searching for the
first occurrence of a specific tag.
Filtering by Tag
A common pattern is extracting only specific parts of speech. For example, pulling adjectives from reviews to build a word cloud:
func extractAdjectives(from text: String) -> [String] {
let tagger = NLTagger(tagSchemes: [.lexicalClass])
tagger.string = text
var adjectives: [String] = []
let range = text.startIndex..<text.endIndex
tagger.enumerateTags(
in: range,
unit: .word,
scheme: .lexicalClass,
options: [.omitWhitespace, .omitPunctuation]
) { tag, tokenRange in
if tag == .adjective {
adjectives.append(String(text[tokenRange]))
}
return true
}
return adjectives
}
let review = """
The stunning visuals and emotional story \
make Inside Out a perfect masterpiece
"""
print(extractAdjectives(from: review))
["stunning", "emotional", "perfect"]
Note: Part-of-speech tagging accuracy depends on language support. English, French, German, Spanish, Italian, and Portuguese have the strongest models. For less-supported languages, results may be less reliable.
Named Entity Recognition
The .nameType tag scheme identifies
personal names, place names, and organization names in text. This powers features like auto-linking mentioned characters
or locations in your Pixar review app.
import NaturalLanguage
struct RecognizedEntity {
let text: String
let type: NLTag
}
func recognizeEntities(
in text: String
) -> [RecognizedEntity] {
let tagger = NLTagger(tagSchemes: [.nameType])
tagger.string = text
var entities: [RecognizedEntity] = []
let range = text.startIndex..<text.endIndex
let options: NLTagger.Options = [
.omitWhitespace, .omitPunctuation, .joinNames
]
tagger.enumerateTags(
in: range,
unit: .word,
scheme: .nameType,
options: options
) { tag, tokenRange in
guard let tag, tag != .otherWord else { return true }
entities.append(
RecognizedEntity(
text: String(text[tokenRange]),
type: tag
)
)
return true
}
return entities
}
let review = """
Lightning McQueen races through Radiator Springs \
while Doc Hudson watches from Ornament Valley
"""
let entities = recognizeEntities(in: review)
for entity in entities {
let padded = entity.text.padding(
toLength: 20, withPad: " ", startingAt: 0
)
print("\(padded) \(entity.type.rawValue)")
}
Lightning McQueen PersonalName
Radiator Springs PlaceName
Doc Hudson PersonalName
Ornament Valley PlaceName
The .joinNames option is key here — without it, “Lightning” and “McQueen” would be tagged as separate personal name
tokens. With .joinNames, the tagger merges consecutive tokens that share the same entity type into a single span.
Combining Tag Schemes
NLTagger can evaluate multiple tag schemes simultaneously. This is more efficient than creating separate taggers for
each scheme:
func fullAnalysis(
of text: String
) -> [(String, NLTag?, NLTag?)] {
let tagger = NLTagger(tagSchemes: [.lexicalClass, .nameType])
tagger.string = text
var results: [(String, NLTag?, NLTag?)] = []
let range = text.startIndex..<text.endIndex
tagger.enumerateTags(
in: range,
unit: .word,
scheme: .lexicalClass,
options: [.omitWhitespace, .omitPunctuation]
) { lexicalTag, tokenRange in
let nameTag = tagger.tag(
at: tokenRange.lowerBound,
unit: .word,
scheme: .nameType
).0
let word = String(text[tokenRange])
results.append((word, lexicalTag, nameTag))
return true
}
return results
}
By initializing the tagger with both .lexicalClass and .nameType, you pay the cost of model loading once instead of
twice. You enumerate over one scheme and query the other using tag(at:unit:scheme:) for each token position.
Sentiment Analysis
The .sentimentScore tag scheme
assigns a floating-point score between -1.0 (most negative) and 1.0 (most positive) to a span of text. This is the
quickest path to gauging user sentiment without building or importing a custom model.
import NaturalLanguage
func analyzeSentiment(of text: String) -> Double {
let tagger = NLTagger(tagSchemes: [.sentimentScore])
tagger.string = text
let range = text.startIndex..<text.endIndex
let (sentimentTag, _) = tagger.tag(
at: range.lowerBound,
unit: .paragraph,
scheme: .sentimentScore
)
return Double(sentimentTag?.rawValue ?? "0") ?? 0.0
}
let positiveReview = """
Toy Story 4 is an absolute masterpiece with \
stunning animation and a heartfelt story
"""
let negativeReview = """
Cars 2 was a disappointing sequel with \
a forgettable plot and dull characters
"""
let neutralReview = """
The movie is about a robot named WALL-E \
who lives on Earth
"""
print("Positive: \(analyzeSentiment(of: positiveReview))")
print("Negative: \(analyzeSentiment(of: negativeReview))")
print("Neutral: \(analyzeSentiment(of: neutralReview))")
Positive: 0.8
Negative: -0.6
Neutral: 0.0
The exact scores will vary slightly by OS version as Apple refines the underlying model. Treat these as directional signals, not absolute values. For per-sentence granularity, tokenize the text into sentences first and score each one individually:
func sentimentPerSentence(
in text: String
) -> [(String, Double)] {
let sentenceTokenizer = NLTokenizer(unit: .sentence)
sentenceTokenizer.string = text
let range = text.startIndex..<text.endIndex
return sentenceTokenizer.tokens(for: range).map { sentenceRange in
let sentence = String(text[sentenceRange])
let score = analyzeSentiment(of: sentence)
return (sentence, score)
}
}
let mixedReview = """
Finding Nemo has gorgeous underwater visuals. \
But the sequel was unnecessary and uninspired.
"""
for (sentence, score) in sentimentPerSentence(in: mixedReview) {
print("\(String(format: "%+.1f", score)) \(sentence)")
}
+0.7 Finding Nemo has gorgeous underwater visuals.
-0.5 But the sequel was unnecessary and uninspired.
Warning: Sentiment analysis is available only for a subset of languages. English has the broadest support. Always check
NLTagger.availableTagSchemes(for:unit:)before relying on sentiment scores for a given language.
Advanced Usage
Custom Word Embeddings
Natural Language ships with built-in word embeddings that let you measure semantic distance between words. This is powerful for building “related terms” features or fuzzy search.
import NaturalLanguage
func findSimilarWords(
to word: String,
maxResults: Int = 5
) -> [(String, Double)] {
guard let embedding = NLEmbedding.wordEmbedding(
for: .english
) else {
return []
}
var results: [(String, Double)] = []
embedding.enumerateNeighbors(
for: word,
maximumCount: maxResults
) { neighbor, distance in
results.append((neighbor, distance))
return true
}
return results
}
let similar = findSimilarWords(to: "adventure")
for (word, distance) in similar {
let padded = word.padding(
toLength: 15, withPad: " ", startingAt: 0
)
print("\(padded) distance: \(String(format: "%.4f", distance))")
}
NLEmbedding also supports
distance(between:and:) for pairwise comparisons, which is useful for de-duplicating tags or clustering similar
reviews.
Constraining the Tagger to a Specific Language
In a multilingual app, you may process text where the language is already known — for instance, after the user selects
their language in settings or after running NLLanguageRecognizer. Setting the language on the tagger avoids redundant
detection and ensures the correct model is loaded:
let tagger = NLTagger(tagSchemes: [.lexicalClass])
tagger.string = review
tagger.setLanguage(
.spanish,
range: review.startIndex..<review.endIndex
)
This is especially important for mixed-language content. You can assign different languages to different ranges of the same string, and the tagger will use the appropriate model for each segment.
Combining with Core ML Custom Models
You can assign a custom NLModel to an NLTagger
for domain-specific classification. For instance, if you train a text classifier in
Create ML to categorize Pixar reviews by theme (humor, emotion, visuals,
soundtrack), you can plug that model into the tagger:
if let customModel = try? NLModel(
mlModel: PixarReviewClassifier().model
) {
let tagger = NLTagger(tagSchemes: [.lexicalClass])
tagger.setModels(
[customModel],
forTagScheme: .lexicalClass
)
tagger.string = review
// Tags now come from your custom model
}
Apple Docs:
NLModel— Natural Language
Performance Considerations
Natural Language operations are CPU-bound and run synchronously on the calling thread. Here is what to keep in mind for production use:
Model loading is the expensive part. The first call to NLTagger or NLEmbedding for a given language triggers
model loading, which can take 10-50ms depending on the scheme and device. Subsequent calls reuse the cached model. If
you are processing text in a tight loop, create the tagger once and reassign tagger.string for each iteration:
// Preferred: reuse the tagger instance across multiple texts
let tagger = NLTagger(tagSchemes: [.sentimentScore])
func batchSentiment(reviews: [String]) -> [Double] {
reviews.map { review in
tagger.string = review
let range = review.startIndex..<review.endIndex
let (tag, _) = tagger.tag(
at: range.lowerBound,
unit: .paragraph,
scheme: .sentimentScore
)
return Double(tag?.rawValue ?? "0") ?? 0.0
}
}
Tokenization is fast. NLTokenizer runs in O(n) time relative to input length and is suitable for real-time use,
even in onChange modifiers on text fields.
Embeddings are memory-intensive. Word embedding models can consume 30-100 MB of memory when loaded. Load them lazily and release references when no longer needed. Do not keep multiple embedding instances alive simultaneously.
Move heavy analysis off the main thread. For batch processing (analyzing hundreds of reviews, for example), dispatch work to a background queue or use Swift concurrency:
func analyzeReviewsConcurrently(
_ reviews: [String]
) async -> [(String, Double)] {
await withTaskGroup(
of: (String, Double).self
) { group in
for review in reviews {
group.addTask {
let score = analyzeSentiment(of: review)
return (review, score)
}
}
var results: [(String, Double)] = []
for await result in group {
results.append(result)
}
return results
}
}
Tip: Profile with Instruments using the Time Profiler template to identify whether model loading or token enumeration is your bottleneck. In most apps, it is the former — and reusing tagger instances eliminates it.
When to Use (and When Not To)
| Scenario | Recommendation |
|---|---|
| Language detection for routing | Use NLLanguageRecognizer. Fast, no network. |
| Tokenization for search indexing | Use NLTokenizer. Handles CJK correctly. |
| Extracting names/places from input | Use NLTagger with .nameType and .joinNames. |
| Basic sentiment for UI feedback | Use .sentimentScore. Good for thumbs-up/down. |
| Fine-grained emotion classification | Train a custom model with Create ML and NLModel. |
| Generative tasks (summarize, rewrite) | Use Foundation Models. NL is analysis-only. |
| Real-time transcription from audio | Use the Speech framework, not Natural Language. |
| Server-side NLP at scale | Use a dedicated NLP service. NL is on-device only. |
Natural Language is the analysis layer in Apple’s on-device intelligence stack. It identifies, tokenizes, tags, and
scores text. It does not generate, translate, or transform it. When you need generation, reach for
Foundation Models. When you need custom classification beyond what the built-in
models offer, train with Create ML and plug the result back into NLTagger.
Summary
NLLanguageRecognizeridentifies the dominant language of any string and returns ranked hypotheses with confidence scores — essential as the first step in any multilingual pipeline.NLTokenizerprovides linguistically correct word, sentence, and paragraph boundaries across all supported languages, including CJK scripts where space-splitting fails.NLTaggerwith the.lexicalClassscheme tags parts of speech, and with.nameTypeextracts personal names, place names, and organizations. Use.joinNamesto merge multi-word entities.- Sentiment analysis via the
.sentimentScorescheme returns a -1.0 to 1.0 score suitable for directional UI feedback, though not fine-grained emotion classification. - Reuse tagger instances across multiple texts to avoid repeated model-loading costs, and move batch analysis off the main thread using Swift concurrency.
For custom text classification beyond built-in capabilities, see
Create ML: Training Custom On-Device Models where you can train
domain-specific classifiers and load them directly into NLTagger via NLModel.