WWDC Quick Look 💓 By SwiftGGTeam
Core Spotlight を使った LLM 検索

Core Spotlight を使った LLM 検索

元の動画を見る

ハイライト

Apple は Core Spotlight インデックスを Foundation Models の Tool Calling へ直接接続しました。開発者は CSSearchableItem を寄付して SpotlightSearchTool を取り付けるだけで、大規模言語モデルがローカルデータを自動検索し、回答を生成できます。独自のベクトルデータベースや手書きの RAG フローは不要です。

主要内容

手書きの検索クエリから、モデル自身が答えを探す形へ

以前、App 内のローカルデータを対象に AI Q&A を作るには、開発者がベクトルデータベースを構築し、Embedding を生成し、Prompt を組み立て、検索結果をモデルのコンテキストに詰め込む必要がありました。この工程は実装量が大きく、デバイス上で動かすには特に重いものでした。

Apple はより短い道を用意しました。SpotlightSearchTool は Foundation Models の Tool プロトコルに準拠し、大規模モデルが App の Core Spotlight インデックスを直接検索できるようにします。モデルはいつ検索するか、何を検索するかを自分で決め、検索結果をコンテキストとして使って回答を生成します。

00:07)Session ではハイキング App を例にしています。ユーザーはすでにいくつかのハイキングルートを完了し、完了するたびに App 内へメモを書いています。ユーザーが「これまでどのハイキングに行った?」と聞くと、モデルは完了日や場所などの属性を検索して答えを組み立てる必要があります。

00:54SpotlightSearchTool がなければ、開発者はモデルに世界知識をもとに推測させるしかありません。これを使うと、モデルが Tool を呼び出して検索クエリを生成し、Spotlight がクエリを実行して結果の説明を返し、モデルはその実データをもとに回答を生成します。

デバイス上モデルの Token 制限にどう対応するか

デバイス上モデルのコンテキストウィンドウは、クラウドモデルよりかなり小さくなります。Spotlight インデックス内のすべてのメタデータをモデルに渡すと、コンテキストが切り詰められたり、推論速度が大きく落ちたりします。

Apple の解決策は GuidanceProfile です。これにより、どの検索能力をモデルに公開するか、どの属性を推論に使わせるかを開発者が細かく制御できます。

08:42)たとえばハイキング App が人物関係を寄付していないなら、people 検索を無効にし、titlealtitudecompletionDate などの中心的な属性だけを残せます。コンテキストがさらに限られるデバイス上モデルでは、.focused(.items) レベルを直接使い、最小限のデータだけを返すこともできます。

これは明確なトレードオフです。モデルの「全体視野」を犠牲にして、デバイス上推論の実用性を得ます。つまり、Spotlight にデータを入れる初期段階から、メタデータをクリーンで構造化された形に設計しておく必要があります。

インデックスにあるのは要約で、モデルには完全なデータが必要

Spotlight インデックスは容量を節約するため、テキスト内容や HTML をコンパクトな形式で保存します。検索はできますが、直接読むことはできません。モデルが Tool を通じて項目 ID を見つけた後、開発者は完全なデータを提供する必要があります。

06:24CSSearchableIndexDelegatesearchableItems(forIdentifiers:) メソッドが追加されました。モデルが ID を見つけると、このメソッドが呼び出されます。開発者は自分のデータベース、たとえば SwiftData や Core Data から完全なエンティティを取得し、CSSearchableItem に組み立てて返します。これにより、モデルは百万件規模の結果セットを効率よく扱いながら、必要なときには完全なメタデータを参照できます。

複雑なクエリは Pipeline に分解する

一度の検索だけでは答えられない質問もあります。たとえば「今年、毎月平均で何マイル歩いた?」という質問では、モデルは完了済みルートを検索し、月ごとに集計し、平均値を計算する必要があります。

09:46SpotlightSearchTool は Pipeline 検索をサポートします。モデルは複雑なクエリを複数段階に分解します。まず検索し、次に数えて表を作り、最後に平均を求めます。各段階は Spotlight 側で実行されるため、大量のデータをモデルコンテキストに戻す必要がありません。

11:34)開発者はカスタム Pipeline 段階も登録できます。たとえばハイキングメモに「楽しさスコア」を付け、モデルが Pipeline 内でその段階を呼び出し、高スコアの結果だけをもとに回答を生成できます。カスタム段階は @Generable でマークされ、モデルはユーザーの Prompt から呼び出しパラメータを動的に生成します。

Evaluations フレームワークで回答品質を検証する

AI の回答品質は、目視で 1 件ずつ確認していては維持できません。Apple は Evaluations フレームワークを提供し、モデルの Tool Calling の軌跡と結果のカバレッジを自動テストできるようにしています。

13:47)開発者はユーザー質問、期待される検索結果 ID、期待される Tool 呼び出し軌跡を含むテストデータセットを定義します。テスト実行時には、まずデータを Spotlight に寄付し、次にモデルに質問へ回答させ、最後に返された結果が期待した項目をカバーしているか確認します。

詳細

基本接続:1 行で 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: "これまでどのハイキングに行った?")

要点:

  • tools 配列には複数の Tool を含められ、どれを呼ぶかはモデルが判断します
  • モデルは 1 回の回答で 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 に App 固有の計算をさせる

11:34)カスタム段階を登録し、Spotlight が検索 Pipeline 内で App 固有の計算を実行できるようにします。

import CoreSpotlight
import FoundationModels

@Generable
struct HappinessStage: CustomStage {
    static var name = "happiness"
    static var description = "著者がどれだけ楽しかったかでハイキングをスコアリングする"
    static var inputTypes: [SearchPipelineDataType] = [.items]
    static var outputTypes: [SearchPipelineDataType] = [.scoredItems]

    @Guide(description: "結果に含める最小の楽しさスコア(0.0-1.0)")
    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("ハイキング検索評価が品質しきい値を満たす")
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, "各クエリの結果カバレッジは少なくとも 50% 必要です")
}

要点:

  • テスト前にデータを Spotlight に寄付し、検索環境をクリーンにします
  • ArrayLoader はテストサンプルセットを読み込みます
  • Metric("ResultCoverage") は期待項目が返却結果に含まれる割合を計算します
  • #expect でカバレッジしきい値をアサートできます

主要な学び

1. 既存 App に対話型検索アシスタントを追加する

App がすでに CSSearchableItem を寄付しているなら、FoundationModels フレームワークを導入し、数行で SpotlightSearchTool を取り付けるだけで、大規模モデルにローカルデータを検索させられます。ユーザーは自然言語で質問でき、モデルが検索戦略を自動的に決めます。

実装の入口:LanguageModelSession を作成し、SpotlightSearchTooltools 配列に追加し、searchResults ストリームを監視して UI を更新します。

2. カスタム Pipeline 段階で App 固有の推論を行う

すべてをメイン LLM にやらせる必要はありません。たとえばメモ App なら、Spotlight 側でメモに感情スコアを付ける SentimentStage を登録できます。ユーザーが「最近、気分がよかったメモはどれ?」と聞いたら、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