WWDC Quick Look 💓 By SwiftGGTeam
LLM search using Core Spotlight

LLM search using Core Spotlight

观看原视频

Highlight

Apple 把 Core Spotlight 索引直接接入 Foundation Models 的 Tool-calling 机制,开发者只需捐赠 CSSearchableItem 并挂载 SpotlightSearchTool,大模型即可自动检索本地数据并生成回答,无需自建向量数据库或手写 RAG 流程。

核心内容

从手写搜索查询到让模型自己找答案

以前要在 App 里做基于本地数据的 AI 问答,开发者得自己搭建向量数据库、写 Embedding、拼 Prompt,把检索结果塞进模型上下文。这套流程工程量大,端侧跑起来更是吃力。

现在 Apple 提供了一条更短的路径。SpotlightSearchTool 遵循 Foundation Models 的 Tool 协议,让大模型可以直接对 App 的 Core Spotlight 索引发起搜索。模型自己决定什么时候查、查什么,然后把搜索结果作为上下文生成回答。

00:07)Session 用一个徒步 App 举例。用户已经完成了一些徒步路线,每完成一条就在 App 里写下笔记。用户问”我去过哪些徒步路线?“,模型需要搜索完成日期、地点等属性才能组织答案。

00:54)没有 SpotlightSearchTool 时,开发者只能让模型基于世界知识瞎猜。有了它,模型会调用 Tool 生成搜索查询,Spotlight 执行查询并返回结果描述,模型再基于这些真实数据生成回答。

端侧模型的 Token 限制怎么办

端侧模型的上下文窗口比云端小得多。如果把 Spotlight 索引里的所有元数据全塞给模型,轻则截断,重则推理速度断崖下跌。

Apple 的解法是 GuidanceProfile。它让开发者精确控制哪些搜索能力暴露给模型、哪些属性可以参与推理。

08:42)比如徒步 App 没有捐赠人物关系,那就可以关闭 people 搜索,只保留 titlealtitudecompletionDate 等核心属性。对于上下文更受限的端侧模型,还可以直接用 .focused(.items) 级别,只返回最精简的数据。

这是一种明确的取舍:牺牲模型的”全局视野”,换取端侧推理的可用性。这也意味着,前期往 Spotlight 塞数据时,元数据必须设计得干净且结构化。

索引里存的是摘要,模型需要完整数据

Spotlight 索引为了节省空间,文本内容和 HTML 以紧凑格式存储,可以搜索但无法直接读取。模型通过 Tool 查到条目 ID 后,需要开发者提供完整数据。

06:24CSSearchableIndexDelegate 新增了 searchableItems(forIdentifiers:) 方法。模型查到 ID 后调用这个方法,开发者从自己的数据库(SwiftData、Core Data 等)拉取完整实体,组装成 CSSearchableItem 返回。这样模型既能高效管理百万级结果集,又能在需要时看到完整元数据。

复杂查询用 Pipeline 拆解

有些问题没法用一次搜索解决。比如”今年我每个月平均徒步多少英里?“,模型需要搜索已完成路线、按月份统计、再算平均值。

09:46SpotlightSearchTool 支持 Pipeline 搜索。模型把复杂查询拆成多个阶段:先搜索、再计数建表、最后求平均。每个阶段在 Spotlight 端执行,避免把大量数据拉回模型上下文。

11:34)开发者还可以注册自定义 Pipeline 阶段。比如给徒步笔记打”开心分数”,模型在 Pipeline 中调用这个阶段,只基于高分结果生成回答。自定义阶段用 @Generable 标记,模型根据用户 Prompt 动态生成调用参数。

用 Evaluations 框架验证回答质量

AI 回答的质量不能靠肉眼逐条检查。Apple 提供了 Evaluations 框架,可以自动化测试模型的 Tool-calling 轨迹和结果覆盖率。

13:47)开发者定义测试数据集,包含用户问题、期望的搜索结果 ID、以及期望的 Tool 调用轨迹。测试运行时,先把数据捐赠给 Spotlight,然后让模型回答问题,最后检查返回结果是否覆盖了期望的条目。

详细内容

基础接入:一行代码让 LLM 搜索你的 App 数据

04:20SpotlightSearchTool 的默认配置就是搜索 App 的 Core Spotlight 索引:

import CoreSpotlight
import FoundationModels

// 默认配置:搜索 App 的 Core Spotlight 索引
let tool = SpotlightSearchTool()

// 或者只搜索 App 沙盒中的文件路径
let fileTool = SpotlightSearchTool(
    configuration: .init(
        sources: [.files]
    )
)

关键点:

  • SpotlightSearchTool() 无需参数即可工作,前提是 App 已经捐赠了 CSSearchableItem
  • sources: [.files] 限制搜索范围到文件路径,适合文档类 App

04:50)把 Tool 加入 LanguageModelSession

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?")

关键点:

  • tools 数组可以包含多个 Tool,模型自行决定调用哪个
  • 模型可能在一次回答中多次调用 SpotlightSearchTool,通过 queryToken 区分不同调用

按需加载完整数据:实现 Index Delegate

06:24)当模型需要完整元数据时,通过 Delegate 向 App 索取:

import CoreSpotlight

class IndexDelegate: NSObject, CSSearchableIndexDelegate {

    // 模型查到 ID 后,调用此方法获取完整 CSSearchableItem
    func searchableItems(forIdentifiers identifiers: [String]) async -> [CSSearchableItem] {
        let entries = await mystore.fetchEntries(ids: identifiers)
        return entries.map { makeSearchableItem(from: $0) }
    }
}

关键点:

  • 这个方法在后台线程被调用,如果数据库查询耗时过长会导致模型推理超时
  • 返回的 CSSearchableItem 可以包含索引中没有的额外属性,专门供模型推理使用
  • 适合把不适合搜索但适合模型理解的元数据(如长文本笔记)放在这里

用 queryToken 管理搜索结果流

07:37)模型可能多次调用 Tool,每次调用产生一批结果。用 queryToken 区分:

let tool = SpotlightSearchTool()

for await reply in tool.searchResults {

    if reply.queryToken != currentToken {
        // 新的搜索调用,重置 UI 列表
        currentToken = reply.queryToken
    }

    switch reply.content {
    case .items(let searchItems):
        // 追加到当前列表
        displayItems.append(contentsOf: searchItems)
    }
}

关键点:

  • searchResultsAsyncSequence,结果分批到达
  • 每次模型发起新的 Tool 调用,queryToken 会变,UI 需要据此判断是追加还是重置
  • 忽略 queryToken 会导致不同搜索批次的数据混在一起

精确控制模型可见的搜索能力

08:42GuidanceProfile 决定模型能使用哪些搜索功能:

let profile = SpotlightSearchTool.GuidanceProfile(
    textMatch: true,
    dates: true,
    people: false,
    attributes: [.title, .altitude, .completionDate]
)

let tool = SpotlightSearchTool(
    configuration: .init(
        guide: .init(level: .dynamic(profile))
    )
)

// 端侧模型上下文更小,用 focused 级别
let focusedTool = SpotlightSearchTool(
    configuration: .init(
        guide: .init(level: .focused(.items))
    )
)

关键点:

  • textMatch 控制是否允许文本匹配搜索
  • datespeople 控制是否允许按日期、人物过滤
  • attributes 精确列出模型可以看到的元数据属性,未列出的属性不会进入模型上下文
  • .focused(.items) 是最精简模式,只返回条目本身,不返回额外元数据

自定义 Pipeline 阶段:让 Spotlight 帮你做计算

11:34)注册自定义阶段,让 Spotlight 在搜索管道中执行 App 特定的计算:

import CoreSpotlight
import FoundationModels

@Generable
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]

    @Guide(description: "Minimum happiness score (0.0-1.0) to include in results")
    var threshold: Double?

    func execute(on input: SearchPipelineData) async throws -> SearchPipelineData {
        // 从 items 中提取笔记,跑情感分析模型
        let scored = input.items.map { item in
            let score = sentimentScore(for: item.attributeSet.contentDescription)
            return ScoredItem(item: item, score: score)
        }.sorted { $0.score > $1.score }

        return SearchPipelineData(payload: .scoredItems(scored))
    }
}

// 注册到 Tool 配置
let tool = SpotlightSearchTool(configuration: .init(
    customStages: [.happinessBoost(threshold: 0.5)]
))

关键点:

  • @Generable 标记让模型可以根据用户 Prompt 动态生成阶段参数
  • @Guide 给属性加描述,帮助模型理解何时使用该阶段
  • inputTypesoutputTypes 声明阶段的数据流类型,模型据此编排 Pipeline
  • 计算在 Spotlight 端执行,不占用模型上下文空间

Pipeline 返回的数据类型

12:10)Pipeline 执行后,结果以多种数据类型返回:

for await reply in tool.searchResults {

    let label = reply.label  // LLM 生成的内容描述标签

    switch reply.content {
    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
    }
}

关键点:

  • reply.label 是模型自动生成的内容描述,可以直接用作 UI 标题
  • .table.statistic 适合回答”统计类”问题,比如”每个月平均多少英里”
  • 根据返回的数据类型选择不同的 UI 渲染方式

用 Evaluations 框架做自动化测试

13:47)定义测试数据集:

import Evaluations

struct TrailRequest: ModelSampleProtocol {

    typealias ExpectedValue = String
    typealias Expectation = TrajectoryExpectation

    var input: ModelSampleInput
    var output: ModelSampleOutput<String, TrajectoryExpectation>

    var expectedIdentifiers: [String]  // 期望搜索返回的条目 ID
}

关键点:

  • ModelSampleProtocol 定义测试样本的输入、期望输出和评估指标
  • expectedIdentifiers 用于验证模型是否找到了正确的数据条目

15:06)定义期望的 Tool 调用轨迹:

TrajectoryExpectation(
    unordered: [
        ToolExpectation("searchSpotlight", arguments: [.keyOnly(argumentName: "query")])
    ]
)

关键点:

  • TrajectoryExpectation 验证模型是否调用了预期的 Tool
  • unordered 表示不严格要求调用顺序
  • arguments: [.keyOnly(argumentName: "query")] 验证调用时传入了 query 参数

15:17)运行测试并检查结果覆盖率:

@Test("Trail search evaluation meets quality thresholds")
func trailSearchEval() async throws {

    let items = try Self.loadItems()
    let samples = try Self.loadSamples()

    // 把测试数据捐赠给 Spotlight
    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()

    // 检查结果覆盖率是否达到 50%
    let coverageMean = result.aggregateValue(.mean(of: Metric("ResultCoverage")))
    #expect(coverageMean >= 0.5, "Result coverage should be at least 50% across queries")
}

关键点:

  • 测试前先捐赠数据到 Spotlight,确保搜索环境干净
  • ArrayLoader 加载测试样本集
  • Metric("ResultCoverage") 计算期望条目在结果中的覆盖比例
  • 可以用 #expect 断言覆盖率阈值

核心启发

1. 给已有 App 加一个对话式搜索助手

如果你的 App 已经捐赠了 CSSearchableItem,只需引入 FoundationModels 框架,加三行代码挂载 SpotlightSearchTool,就能让大模型检索你的本地数据。用户可以用自然语言提问,模型自动决定搜索策略。

实现思路:创建 LanguageModelSession,把 SpotlightSearchTool 加入 tools 数组,监听 searchResults 流更新 UI。

2. 用自定义 Pipeline 阶段做 App 专属推理

不要什么都让主 LLM 做。比如笔记 App 可以注册一个 SentimentStage,在 Spotlight 端给笔记打情感分数。用户问”我最近心情好的笔记有哪些?“,Pipeline 先过滤出高分笔记,再交给模型生成回答。

实现思路:实现 CustomStage 协议,用 @Generable@Guide 标记,注册到 SpotlightSearchToolcustomStages 配置中。

3. 用 GuidanceProfile 适配端侧模型

端侧模型上下文有限,默认配置可能索要过多数据。根据你的数据特点裁剪 GuidanceProfile,关闭不需要的搜索能力,精确列出模型可见的属性。元数据质量直接决定 AI 回答的上限。

实现思路:分析你的 CSSearchableItemAttributeSet 中哪些属性对回答有帮助,在 GuidanceProfile 中显式声明,未声明的属性不会进入模型上下文。

4. 用 Evaluations 框架建立回归测试

AI 回答的质量会随模型版本、数据变化而波动。用 ModelSampleProtocol 定义测试集,覆盖常见用户问题,自动化验证 Tool-calling 轨迹和结果覆盖率。

实现思路:准备一批测试问题和期望答案,用 TrajectoryExpectation 验证模型是否调用了 SpotlightSearchTool,用 ResultCoverage 指标验证返回的条目是否完整。

5. 用 ContactResolver 解决”我”是谁的问题

当用户问”我和谁去的?“,模型需要知道”我”指谁。实现 ContactResolver 返回当前用户的身份信息,帮助 Spotlight 过滤出正确的结果。

实现思路:实现 ContactResolver 协议,从 App 的账户系统或 Contacts 框架获取用户信息,设置到 tool.contactResolver

关联 Session

评论

GitHub Issues · utterances