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

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.languageHints to 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)

ScenarioRecommendation
Language detection for routingUse NLLanguageRecognizer. Fast, no network.
Tokenization for search indexingUse NLTokenizer. Handles CJK correctly.
Extracting names/places from inputUse NLTagger with .nameType and .joinNames.
Basic sentiment for UI feedbackUse .sentimentScore. Good for thumbs-up/down.
Fine-grained emotion classificationTrain a custom model with Create ML and NLModel.
Generative tasks (summarize, rewrite)Use Foundation Models. NL is analysis-only.
Real-time transcription from audioUse the Speech framework, not Natural Language.
Server-side NLP at scaleUse 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

  • NLLanguageRecognizer identifies the dominant language of any string and returns ranked hypotheses with confidence scores — essential as the first step in any multilingual pipeline.
  • NLTokenizer provides linguistically correct word, sentence, and paragraph boundaries across all supported languages, including CJK scripts where space-splitting fails.
  • NLTagger with the .lexicalClass scheme tags parts of speech, and with .nameType extracts personal names, place names, and organizations. Use .joinNames to merge multi-word entities.
  • Sentiment analysis via the .sentimentScore scheme 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.