Searching indexed content with natural language
Give a language model access to your app’s Core Spotlight index to enable natural-language queries over searchable content.
Overview
This sample demonstrates SpotlightSearchTool, a type that connects a Foundation Models language-model session to your app’s Core Spotlight index. Using SpotlightSearchTool, the language model can search, filter, and reason about your indexed content, turning a metadata-based index into a conversational search experience.
[Image]
The app indexes a collection of hiking trail entries as CSSearchableItem objects, then lets people ask natural-language questions like “Which trails in California have water features?” The language model uses the tool to query the index and streams a response alongside the matching trail results.
Configure the sample code project
This sample requires a device that supports Apple Intelligence, running iOS 27 or later.
Before you build and run the sample, turn on Apple Intelligence by opening Settings > Apple Intelligence & Siri.
By default, the sample runs searches on the on-device system language model, so the project builds and runs without additional configuration. For best search performance, route searches to Private Cloud Compute (PCC) models designed for Apple Intelligence. For more information, see Adopt Private Cloud Compute.
Provide search attributes to the language model
The sample creates a SpotlightSearchTool configured with a Core Spotlight source to let the language model search the indexed content. The fetchAttributes parameter specifies which item attributes the tool returns to the model, providing the information it uses to answer questions about trails. The sample includes both built-in attributes and a custom distance attribute that the app indexes for each trail:
let fetchAttributes: [SearchableItemAttribute] = [
.title,
.contentDescription,
.namedLocation,
.stateOrProvince,
.keywords,
.latitude,
.longitude,
.rating,
.duration,
.contentCreationDate,
.completionDate,
SearchableItemAttribute(rawValue: distanceAttributeKey.keyName)
]Adopt Private Cloud Compute
By default, the sample searches your app’s Spotlight index using the on-device SystemLanguageModel, so the project builds and runs without additional configuration.
The sample’s view model exposes the language model it uses as a foundationModel property:
// Initialize `foundationModel` with an on-device model so the sample works without additional configuration:
let foundationModel = SystemLanguageModel()
// Alternatively, initialize `foundationModel` with a PCC language model.
// let foundationModel = PrivateCloudComputeLanguageModel()For best search performance that takes advantage of server models, route searches through Private Cloud Compute (PCC) instead. For eligibility and setup, see Adding server-side intelligence with Private Cloud Compute.
To adopt PCC, initialize foundationModel with PrivateCloudComputeLanguageModel. When foundationModel is the PCC model, the sample uses the SpotlightSearchTool.GuidanceLevel.complete guide for richer query construction; on device. When foundationModel is the on-device model, the sample uses the SpotlightSearchTool.GuidanceLevel.focused(_:) guide and provides more explicit search instructions to suit the smaller model.
// A Boolean value that indicates whether the search tool uses an on-device or PCC model.
private var isOnDevice: Bool {
type(of: foundationModel) == SystemLanguageModel.self
}
// Initializes the Spotlight search tool and guides it to
// perform a `.focused()` search when using an on-device model and a
// `.complete` search when using Private Cloud Compute models.
private func makeSpotlightTool() -> SpotlightSearchTool {
SpotlightSearchTool(
configuration: .init(
sources: [
.coreSpotlight(
.init(
searchableIndexDelegate: SpotlightIndexer.shared,
fetchAttributes: Self.fetchAttributes
)
)
],
guide: isOnDevice ? .focused() : .complete
)
)
}Stream responses from the language model
The sample passes the search tool to a LanguageModelSession along with system instructions that describe the indexed data. When a person submits a query, the session calls the tool to find matching entries and streams a natural-language response. The sample creates a fresh session and tool for each search so every query starts with fresh context:
let session = LanguageModelSession(
model: foundationModel,
tools: [tool],
instructions: instructions
)
do {
for try await chunk in session.streamResponse(to: prompt) {
response = chunk.content
}
} catch {
self.error = error.localizedDescription
}Display search results alongside the response
The search tool provides an asynchronous stream of search replies as the model processes the query. Each reply’s content is a discriminated union: matches arrive as SpotlightSearchTool.SearchReply.Content.items(_:), SpotlightSearchTool.SearchReply.Content.scoredItems(_:), or SpotlightSearchTool.SearchReply.Content.groupedItems(_:) that provide wrapped SearchableItem results. Additionally, the model may also return other SpotlightSearchTool.SearchReply.Content enumeration values as replies, depending on the query.
The sample listens for results on this stream and updates the UI as items arrive, so trail cards appear before the model finishes generating its text summary. Because the model can issue multiple queries while refining results, the sample deduplicates by uniqueIdentifier to avoid showing the same trail twice. The sample unwraps each SearchableItem to the underlying CSSearchableItem at this boundary, and the rest of the UI works directly with Core Spotlight’s own item type:
private func listenForSearchResults(from tool: SpotlightSearchTool) -> Task<Void, Never> {
Task { @MainActor in
var seen: Set<String> = []
for await reply in tool.searchResults {
let items: [CSSearchableItem]
switch reply.content {
case .items(let searchItems):
items = searchItems.map(\.item)
case .scoredItems(let scored):
items = scored.map(\.item.item)
case .groupedItems(let groups):
items = groups.values.flatMap { $0 }.map(\.item)
case .count, .table, .statistic, .text:
continue
@unknown default:
continue
}
let newItems = items.filter { seen.insert($0.uniqueIdentifier).inserted }
self.results.append(contentsOf: newItems)
}
}
}Index searchable items with Core Spotlight
The sample loads trail data from a property list at launch and indexes each entry as a CSSearchableItem. Each item includes attributes like title, location, keywords, and duration. The indexer uses beginBatch() and endBatch(withClientState:completionHandler:) to group the work into a single transaction, and records client state so it can skip reindexing on subsequent launches:
func indexAllItems() async {
let items = createSearchableItems()
guard !items.isEmpty else { return }
var isIndexed = true
let newState = Data(bytes: &isIndexed, count: MemoryLayout.size(ofValue: isIndexed))
do {
index.beginBatch()
try await index.indexSearchableItems(items)
try await index.endBatch(withClientState: newState)
} catch {
print("Batch index failed: \(error.localizedDescription)")
}
}The indexer conforms to CSSearchableIndexDelegate so the system can request full searchable items when needed during hydration, which enriches the generated response.