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, so the project builds and runs without additional configuration. For best performance, route searches through Private Cloud Compute (PCC). For additional information, see Adopt Private Cloud Compute.
Create a search tool for 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)
]
let tool = SpotlightSearchTool(
configuration: .init(
sources: [
.coreSpotlight(
.init(
searchableIndexDelegate: SpotlightIndexer.shared,
fetchAttributes: fetchAttributes
)
)
],
guide: .focused()
)
)Adopt Private Cloud Compute
By default, the sample runs searches on the on-device SystemLanguageModel, so the project builds and runs without additional configuration. The view model exposes the model it uses as a serverModel property:
let serverModel = SystemLanguageModel()To route searches through Private Cloud Compute (PCC) instead, initialize serverModel with PrivateCloudComputeLanguageModel. When serverModel is the PCC model, the search tool uses the SpotlightSearchTool.GuidanceLevel.complete guide for richer query construction; on device, it uses SpotlightSearchTool.GuidanceLevel.focused(_:) and provides more explicit search instructions to suit the smaller model. For eligibility and setup, see Adding server-side intelligence with Private Cloud Compute.
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: serverModel,
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.