How to Classify Text with LLMs in R
Introduction
Text classification assigns categories to text. Traditional approaches require labeled training data and ML models. With LLMs, you can classify text with just a prompt - no training needed.
Common use cases:
- Categorize support tickets (billing, technical, account)
- Tag documents by topic (finance, legal, HR)
- Classify emails by intent (inquiry, complaint, feedback)
- Label survey responses by theme
Why Use LLMs for Classification?
| Approach | Training Data | Setup Time | Flexibility |
|---|---|---|---|
| Traditional ML | Thousands of examples | Days/weeks | Fixed categories |
| LLMs | Zero examples | Minutes | Change categories anytime |
LLMs excel when:
- You don’t have labeled training data
- Categories may change over time
- You need quick prototyping
Getting Started
Load the ellmer package for LLM access:
library(ellmer)
library(tidyverse)This tutorial works with any provider. See ellmer basics for setup.
Basic Classification
Single category classification
Start with a simple prompt that asks the LLM to classify text:
chat <- chat_claude()
text <- "I can't log into my account and I've tried resetting my password three times."
response <- chat$chat(paste(
"Classify this support ticket into one category:",
"billing, technical, account, or other.",
"Reply with just the category name.",
"\n\nTicket:", text
))
response
# "account"The prompt has three parts:
- Task: “Classify this support ticket”
- Categories: “billing, technical, account, or other”
- Format: “Reply with just the category name”
Why “just the category name”?
Without format instructions, LLMs tend to explain their reasoning:
# Without format instruction - verbose response
chat$chat("Classify this as billing/technical/account: I can't log in")
# "This appears to be an account-related issue because..."
# With format instruction - clean response
chat$chat("Classify as billing/technical/account. One word only: I can't log in")
# "account"Clean responses are easier to process programmatically.
Structured Classification
Using extract_data for reliable output
For production use, extract_data() guarantees structured output:
# Define the allowed categories
category_type <- type_enum(
values = c("billing", "technical", "account", "other"),
description = "Support ticket category"
)The type_enum() ensures the response is one of the allowed values.
Extract the category
chat <- chat_claude()
ticket <- "Why was I charged twice this month? Please refund the extra charge."
category <- chat$extract_data(ticket, type = category_type)
category
# "billing"This approach:
- Guarantees valid output (no unexpected categories)
- Returns clean data (no extra text)
- Works reliably in automated pipelines
Multi-label Classification
When text belongs to multiple categories
Some text fits multiple categories. Use an array type:
categories_type <- type_array(
items = type_enum(
values = c("urgent", "billing", "technical", "account", "feedback"),
description = "Applicable category"
),
description = "All categories that apply to this ticket"
)Classify with multiple labels
chat <- chat_claude()
ticket <- "URGENT: My payment failed and now I'm locked out of my account!"
labels <- chat$extract_data(ticket, type = categories_type)
labels
# ["urgent", "billing", "account"]The ticket is urgent, involves billing (payment failed), and account access.
Classification with Confidence
Get confidence scores
Sometimes you want to know how confident the model is:
classification_type <- type_object(
category = type_enum(
values = c("billing", "technical", "account", "other"),
description = "Primary category"
),
confidence = type_number("Confidence score from 0 to 1"),
reasoning = type_string("Brief explanation for the classification")
)Extract classification with confidence
chat <- chat_claude()
ticket <- "The app crashes when I try to upload files larger than 10MB"
result <- chat$extract_data(ticket, type = classification_type)
result$category
# "technical"
result$confidence
# 0.95
result$reasoning
# "This describes a software bug related to file uploads"Low confidence scores indicate ambiguous cases that may need human review.
Batch Classification
Create a reusable classifier
Wrap classification in a function for reuse:
classify_ticket <- function(text, chat = NULL) {
if (is.null(chat)) {
chat <- chat_claude()
}
category_type <- type_enum(
values = c("billing", "technical", "account", "other"),
description = "Ticket category"
)
chat$extract_data(text, type = category_type)
}Classify multiple texts
Use purrr’s map() to process a vector of texts:
library(purrr)
tickets <- c(
"I was charged twice for my subscription",
"The export feature isn't working",
"How do I change my email address?",
"Your product is amazing, thank you!"
)
# Add delay to respect rate limits
categories <- map_chr(tickets, \(ticket) {
Sys.sleep(0.5)
classify_ticket(ticket)
})
categories
# ["billing", "technical", "account", "other"]The Sys.sleep(0.5) prevents hitting API rate limits.
Create a classified data frame
tibble(
ticket = tickets,
category = categories
)| ticket | category |
|---|---|
| I was charged twice… | billing |
| The export feature… | technical |
| How do I change… | account |
| Your product is… | other |
Hierarchical Classification
Two-level categories
For complex taxonomies, classify in stages:
# First level: main category
main_category <- type_enum(
values = c("technical", "billing", "account"),
description = "Main category"
)
# Second level: subcategories for technical issues
technical_subcategory <- type_enum(
values = c("bug", "feature_request", "how_to", "performance"),
description = "Technical subcategory"
)Two-stage classification
classify_hierarchical <- function(text) {
chat <- chat_claude()
# Stage 1: Main category
main <- chat$extract_data(text, type = main_category)
# Stage 2: Subcategory (only for technical)
sub <- if (main == "technical") {
chat$extract_data(text, type = technical_subcategory)
} else {
NA
}
list(category = main, subcategory = sub)
}
result <- classify_hierarchical("The dashboard loads very slowly")
# $category: "technical"
# $subcategory: "performance"Custom Categories
Define your own taxonomy
LLMs work with any categories you define:
# Document types
doc_type <- type_enum(
values = c("contract", "invoice", "report", "memo", "other"),
description = "Document type"
)
# Email intents
email_intent <- type_enum(
values = c("inquiry", "complaint", "request", "thank_you", "spam"),
description = "Email intent"
)
# News topics
news_topic <- type_enum(
values = c("politics", "business", "technology", "sports", "entertainment"),
description = "News article topic"
)Use with any text
chat <- chat_claude()
article <- "Apple announced record quarterly earnings driven by iPhone sales in Asia."
topic <- chat$extract_data(article, type = news_topic)
# "business"Improving Accuracy
Add context to the system prompt
Provide domain context for better classification:
chat <- chat_claude(
system_prompt = "You are a customer support classifier for a SaaS company.
Category definitions:
- billing: payments, charges, refunds, subscriptions, pricing
- technical: bugs, errors, features not working, integrations
- account: login, password, profile, settings, permissions
- other: feedback, partnerships, general questions"
)
category_type <- type_enum(
values = c("billing", "technical", "account", "other"),
description = "Ticket category"
)
ticket <- "Can you add dark mode to the app?"
chat$extract_data(ticket, type = category_type)
# "other" (feature request, not a bug)Provide examples (few-shot)
Include examples in the prompt for tricky cases:
chat <- chat_claude(
system_prompt = "Classify support tickets. Examples:
'Charge me yearly instead of monthly' -> billing
'Button doesn't work on mobile' -> technical
'Reset my 2FA' -> account
'Love your product!' -> other"
)Error Handling
Handle API failures gracefully
safe_classify <- function(text) {
tryCatch({
chat <- chat_claude()
category_type <- type_enum(
values = c("billing", "technical", "account", "other"),
description = "Category"
)
chat$extract_data(text, type = category_type)
}, error = function(e) {
warning("Classification failed: ", e$message)
NA_character_
})
}
# Use with map
results <- map_chr(tickets, safe_classify)
# Filter out failures
valid_results <- results[!is.na(results)]Local Classification
Use Ollama for free, private classification
For sensitive data or high volume, use local models:
chat <- chat_ollama(model = "llama3.2")
category_type <- type_enum(
values = c("billing", "technical", "account", "other"),
description = "Category"
)
chat$extract_data("I need a refund", type = category_type)
# "billing"See Local LLMs with Ollama for setup.
Note: Local models may be less accurate than cloud APIs for nuanced classification.
Performance Tips
Batch similar classifications
Process items in batches when possible:
# Instead of classifying one at a time
# Classify multiple items with context
batch_classify <- function(texts) {
chat <- chat_claude()
# Build numbered list
numbered <- paste(seq_along(texts), texts, sep = ". ", collapse = "\n")
prompt <- paste(
"Classify each item as billing/technical/account/other.",
"Return a JSON array of categories in order.",
"\n\n", numbered
)
# Parse JSON response
jsonlite::fromJSON(chat$chat(prompt))
}Use cheaper models for simple tasks
# Use Haiku for simple classification
chat <- chat_claude(model = "claude-haiku-4-5")
# Use Opus only for complex multi-label tasks
chat <- chat_claude(model = "claude-opus-4-6")Summary
| Task | Code |
|---|---|
| Simple classification | chat$chat("Classify as X/Y/Z: text") |
| Structured output | chat$extract_data(text, type_enum(...)) |
| Multi-label | type_array(items = type_enum(...)) |
| With confidence | type_object(category = ..., confidence = ...) |
| Batch processing | map_chr(texts, classify_fn) |
Key points:
- Use
type_enum()to guarantee valid categories - Add system prompts with category definitions for accuracy
- Use
Sys.sleep()in batch processing for rate limits - Consider local models for sensitive or high-volume data