2026 AI & Machine Learning
WWDC26 · 16 min · AI & Machine Learning
LLM search using Core Spotlight
Level up basic search into a retrieval-augmented system using SpotlightSearchTool and LanguageModelSession. Explore Core Spotlight integration, delegate-based hydration patterns, and how metadata quality impacts your search results. Learn how to use custom PipelineStages for tasks like sentiment analysis. Discover best practices for indexing and building flexible, context-rich search experiences in your app.
Watch at developer.apple.com ↗Chapters
- 0:00 — Introduction
- 1:41 — Grounding answers with Spotlight tool-calling
- 4:00 — Configure and add SpotlightSearchTool
- 6:44 — Displaying results and partial replies
- 6:46 — Provide full items with an index delegate
- 8:12 — Customizing with guidance profiles
- 11:02 — Reference resolution with a contact resolver
- 11:24 — Custom pipeline stages
- 12:47 — Evaluating response quality
- 15:53 — Next steps
Code shown on screen · 12 snippets
Ask the model with a Foundation Models session
let response = try await session.respond(to: "What are some nice hikes near water?") Set up SpotlightSearchTool
// Set up SpotlightSearchTool
import CoreSpotlight
import FoundationModels
// In one line, the tool is ready to search your app's Core Spotlight index
let tool = SpotlightSearchTool()
// Or provide a custom configuration — e.g. search file paths in your app's sandbox
let fileTool = SpotlightSearchTool(
configuration: .init(
sources: [
.files
]
)
) Add SpotlightSearchTool to a session
// Add SpotlightSearchTool to a session
import CoreSpotlight
import FoundationModels
let tool = SpotlightSearchTool()
let session = LanguageModelSession(model: model, tools: [tool], instructions: instructions)
let response = try await session.respond(to: "What hikes have I gone on?") Implement an index delegate
// Implement an index delegate
import CoreSpotlight
class IndexDelegate: NSObject, CSSearchableIndexDelegate {
// Called when the index requests searchable items for the provided identifiers
func searchableItems(forIdentifiers identifiers: [String]) async -> [CSSearchableItem] {
let entries = await mystore.fetchEntries(ids: identifiers)
return entries.map { makeSearchableItem(from: $0) }
}
} Track the query token for refresh
// Track the query token for refresh
import CoreSpotlight
import FoundationModels
let tool = SpotlightSearchTool()
for await reply in tool.searchResults {
if reply.queryToken != currentToken {
// New query — start a new display section
currentToken = reply.queryToken
}
switch reply.content {
case .items(let searchItems):
}
} Set a dynamic guidance profile
// Set a dynamic guidance profile
import CoreSpotlight
import FoundationModels
let profile = SpotlightSearchTool.GuidanceProfile(
textMatch: true,
dates: true,
people: false,
attributes: [.title, .altitude, .completionDate]
)
let tool = SpotlightSearchTool(
configuration: .init(
guide: .init(level: .dynamic(profile))
)
)
// On-device models have smaller context — prefer focused guidance
let focusedTool = SpotlightSearchTool(
configuration: .init(
guide: .init(level: .focused(.items))
)
) Implement a ContactResolver
// Implement a ContactResolver
import CoreSpotlight
import FoundationModels
struct MyContactResolver: ContactResolver {
func userIdentity() -> ResolvedContact {
// Pull from whatever identity source your app has —
// account profile, Contacts framework, sign-in session, etc.
var contact = ResolvedContact(displayName: "Jane Doe")
contact.emailAddresses = ["[email protected]", "[email protected]"]
contact.names = ["Jane", "JD"]
return contact
}
}
tool.contactResolver = MyContactResolver() Define a custom stage
// Define a custom stage
import CoreSpotlight
import FoundationModels
struct HappinessStage: CustomStage {
static var name = "happiness"
static var description = "Scores hike by how happy the author was"
static var inputTypes: [SearchPipelineDataType] = [.items]
static var outputTypes: [SearchPipelineDataType] = [.scoredItems]
(description: "Minimum happiness score (0.0-1.0) to include in results")
var threshold: Double?
func execute(on input: SearchPipelineData) async throws -> SearchPipelineData {
return SearchPipelineData(payload: .scoredItems(sorted))
}
}
// Register the stage by adding it to the tool's configuration
let tool = SpotlightSearchTool(configuration: .init(
customStages: [.happinessBoost(threshold: 0.5)])
) Handle a reply data types
// Handle a reply data types
import CoreSpotlight
import FoundationModels
for await reply in tool.searchResults {
let label = reply.label
case .items(let searchItems):
case .scoredItems(let scored):
case .groupedItems(let groups):
case .count(let count):
case .table(let table):
case .statistic(let statistic):
case .text(let text):
continue
}
} Define an evaluation dataset with ModelSampleProtocol
// Evaluations
import Evaluations
struct TrailRequest: ModelSampleProtocol {
typealias ExpectedValue = String // sample response
typealias Expectation = TrajectoryExpectation
var input: ModelSampleInput
var output: ModelSampleOutput<String, TrajectoryExpectation>
var expectedIdentifiers: [String]
} Define the trajectory expectation
// Evaluations
import Evaluations
TrajectoryExpectation(
unordered: [
ToolExpectation("searchSpotlight", arguments: [.keyOnly(argumentName: "query")])
]
) Run the evaluation test —
("Trail search evaluation meets quality thresholds")
func trailSearchEval() async throws {
let items = try Self.loadItems()
let samples = try Self.loadSamples()
try await Self.indexDelegate.indexSearchableItems(items)
let tool = Self.makeSearchTool()
let evaluation = TrailSearchEvaluation(
tool: tool,
dataset: ArrayLoader(samples: samples)
)
let result = try await evaluation.run()
let coverageMean = result.aggregateValue(.mean(of: Metric("ResultCoverage")))
#expect(coverageMean >= 0.5, "Result coverage should be at least 50% across queries")
}