WWDC26:LLM search using Core Spotlight

これぞまさに昨年の筆者がやりたかったこと。紹介されているユースケースは取り組んでいたアイデアに近いものがあった。パイプライン実装により複雑さや規模をともなう検索も実現できるのが興味深い。

LLM search using Core Spotlight


0:00 – Introduction

  • アプリコンテンツを言語モデルに提供して会話型検索を構築する方法の紹介
  • デモアプリ:州立公園とトレイルを閲覧し、完了したハイキングのメモを保存するアプリ

1:41 – Grounding answers with Spotlight tool-calling

  • LanguageModelSession だけでは世界知識で答えるためアプリのデータを参照できない
  • Core Spotlight インデックスを Tool プロトコル経由で参照することで LLM の回答をアプリデータに接地(グラウンディング)
  • SpotlightSearchTool:iOS・iPadOS・macOS・visionOS 対応
    • 前提:CSSearchableItem でコンテンツをインデックスに提供済みであること

4:00 – Configure and add SpotlightSearchTool

  • SpotlightSearchTool を組み込み時の確認事項
    • 検索の種類に合わせたツールの設定
      • SpotlightSearchTool.init(configuration:) にカスタム設定を渡すことが可能(e.g. .files サンドボックス内のファイルパスへの検索)
    • モデルへの追加のコンテクスト提供
      • Model Provider API
    • UIで結果を表示する方法
  • セッションへの追加は LanguageModelSession のイニシャライザに SpotlightSearchTool のインスタンスを指定
  • ツール呼び出しの流れ:ユーザークエリ → LLM が SpotlightSearchTool を呼び出す → Spotlight で検索 → 結果を LLM に返す → 根拠のある回答を生成
  • 提供済みのメタデータがコンパクトに保存されるためモデルが読めない場合がある
  • CSSearchableIndexDelegatesearchableItems(forIdentifiers:) を実装して識別子からフルアイテムを復元
    • モデルが推論に使える追加属性を付与できる

6:44 – Displaying results and partial replies

  • 検索結果は SpotlightSearchTool 自体からも取得可能
  • セッションレスポンス:アシスタント UI(自然言語の回答)に適す
  • ツールの searchableItems:リスト UI(具体的なアイテムの一覧表示)に適す
  • tool.searchResults 非同期シーケンスで結果をストリーミング受信
  • queryToken でリフレッシュタイミングを管理
    • モデルが 1 回のレスポンスでツールを複数回呼び出す場合があるため、各リプライの queryToken の変化を検知する

8:12 – Customizing with guidance profiles

  • GuidanceProfile でモデルに渡す Spotlight の検索能力をカスタマイズする
    • 必要な属性・機能だけを有効化することでオンデバイスモデルの限られたコンテキストを節約
  • オンデバイスモデル向けには .focused(.items) ガイドレベルを推奨

11:02 – Reference resolution with a contact resolver

  • 「誰と一緒にハイキングしたか?」のような人物参照クエリへの対応
  • ContactResolver プロトコルを実装してユーザーの連絡先情報を提供
    • 表示名・メールアドレス・名前のバリエーションを含む ResolvedContact を返す
    • ツールが氏名の曖昧さを解消して正しい結果を絞り込む

11:24 – Custom pipeline stages

  • 複雑なリクエストに対してモデルが検索+計算の複数ステージを実行できるパイプラインを定義
  • @GenerableCustomStage を実装して登録
    • 例:ハイキングメモの「幸福度スコア」計算ステージ
    • inputTypesoutputTypesexecute(on:) を実装
    • スコア付きアイテム・グループ化アイテム・カウントなど様々な SearchPipelineDataType を扱える
  • ツールの設定に customStages として追加するだけでモデルが必要に応じて呼び出す

12:47 – Evaluating response quality

  • Evaluations フレームワークでツール呼び出しとレスポンス品質を計測
  • ModelSampleProtocol でデータセットを定義
    • 期待する識別子リストとツール呼び出しの軌跡(TrajectoryExpectation)を含む
  • Sample Generation API でシードサンプルを拡充
  • テストでメトリクスを検証

WWDC26:What’s new in the Foundation Models framework

今年も自分の勉強用に、日々セッションのサマリをやってみる。まずは、去年散々格闘した Foundation Models から。今年はオンデバイスモデルが大幅進化していたり、クラウドコンピューティングが利用できるようになったり、画像理解もできるようになったりと大幅進化を遂げて嬉しいのだが、何より、去年挫折した Spotlight + RAG のユースケースを、SpotlightSearchTool として公式に提供されたことは嬉しい。

What’s new in the Foundation Models framework


0:00 – Introduction

  • Foundation Models framework 2年目のリリース概要
  • 今年の 3 つのテーマ:
    • OS の内外への連携の拡大
    • より多様なモデルのサポート
    • エージェント体験構築のための新しい基本要素
  • フレームワークコアが オープンソース化
  • 新パッケージ Foundation Models framework utilities も同時リリース
    • OS リリース間でも更新され、最新の構成要素にアクセス可能
    • このセッションを通してFMエコシステムに含まれる他のフレームワークも紹介する

2:34 – New on-device model

  • オンデバイスモデルをゼロから再構築しより賢く
    • tool calling の精度と論理処理能力の向上
  • iOS 26.4 で追加された新 API:
    • モデルの context size を検査
    • instructions・prompts・transcripts のトークン数をカウント
    • 動いているハードウェアに合わせてアプリを適応させるために活用
  • ガードレールの継続的改善
    • iOS 26.4 で誤検知を削減、iOS 27 でさらに改善予定

3:21 – Vision:image understanding

  • オンデバイスモデルに Vision 機能が追加
  • API は既存の prompt builder の自然な拡張
    • LanguageModelSession を作成し、prompt にテキストと一緒に画像を添付して渡す(e.g. Bundle.main.url(forResource:withExtension:))
    • 画像の内容についてそのまま質問できる
  • ImageAttachment がサポートする型:
    • UIImageNSImageCGImage
    • Core Image 型
    • CoreVideo Pixel Buffers
    • ファイル URL
  • 任意のサイズ・アスペクト比をサポート(クロップ・パッディング不要)
  • 注意:画像が大きいほどトークン消費とレイテンシが増加

4:20 – Private Cloud Compute (PCC)

  • PrivateCloudComputeLanguageModel が新登場
    • Apple Intelligence の各機能を動かしているのと同じモデル
    • オンデバイスモデルより大幅に大きなモデル
    • コンテキストウィンドウ:32,000 トークン
    • reasoning 機能搭載 — 回答前に時間をかけて思考することで精度が大幅向上
  • 使い方:
    • PrivateCloudComputeLanguageModel のインスタンスを作成し LanguageModelSession に渡すだけ
    • contextOptionsreasoningLevel で思考の深さを指定
      • 推論が深いほど高品質だが計算コスト大
  • メリット:
    • アカウント設定・認証・API キーの保管が一切不要
    • プロンプトは保存されない(プライバシー保護)
    • 独立した研究者による検証が可能な仕組み
  • watchOS 27 でも Foundation Models framework が利用可能に(PCC 経由)
  • 料金:初回ダウンロードが 200 万件未満の開発者は無料。iCloud+ ユーザーはさらに利用枠が拡大

6:46 – Model abstraction layer

  • 新しい LanguageModel プロトコルでほぼあらゆる言語モデルを Framework 経由で利用可能に
    • ローカル・サーバー問わず LanguageModelSession のバックエンドとして使える
    • 既存のモデル SystemLanguageModel PrivateCloudComputeLanguageModel はすでに準拠済み
  • オープンソースで追加実装を公開:
    • CoreAILanguageModel — Apple Neural Engine 上で多様なローカルモデルを実行
    • MLXLanguageModel — Mac の GPU 上で実行

7:32 – Partner model integrations

  • Anthropic・Google が Swift パッケージを公開し、フロンティアモデルへのアクセスを提供
  • 使い方:Swift Package Manager でパッケージを追加し、モデルを初期化して LanguageModelSession に渡すだけ
    • 呼び出し元のコードは変更不要
  • 注意事項:
    • 認証・課金はサードパーティごとに自前対応が必要
    • API キーを絶対にアプリバイナリに埋め込まないこと — OAuth などの安全な仕組みでアクセストークンを取得し Keychain に保存
  • トークン使用量の追跡:
    • SessionResponseusage プロパティが追加
    • 使用トークン数・キャッシュから読んだ入力トークン数・推論に使ったトークン数を把握可能

9:40 – System tools:Vision and Spotlight

  • Vision framework を使った2つの組み込みツールが追加
    • BarcodeReaderTool — バーコードから情報を読み取り
    • OCRTool — 画像から構造化テキストを抽出
    • モデルが単独では難しい視覚情報の推論をサポート
  • SpotlightSearchTool Spotlight を使ったローカル RAG(Retrieval-Augmented Generation)ツールも追加
    • 完全ローカルで最新の個人・ドメイン知識をモデルに提供
    • Spotlight インデックスと特殊処理クエリを活用
    • クラウド API コストなし・プライバシー保護

10:57 – Dynamic Profiles for agentic apps

  • DynamicProfile — エージェント体験構築のための新しい宣言的 API
    • 従来:コンテキスト・モデル・ツールを複数セッションにまたがって命令的に管理するボイラープレートが多い
    • 新設計:単一の LanguageModelSession 内でコンテキストを宣言的に記述
  • 基本的な使い方:
    • DynamicProfile プロトコルに準拠した struct を定義
    • body プロパティで Profile を返す(instructions・tools を含む)
    • LanguageModelSession.init(profile: DynamicProfile) で初期化
  • 応用:
    • アプリの状態変数に応じて分岐し、それぞれの Profile で異なる instructions・tools を持たせる
    • モデルが自律的にモードを切り替えるためのツールを Profile に持たせることも可能

13:46 – Composing models and configurations

  • DynamicProfile ではモデルと設定も動的に切り替え可能
    • 例:分析タスクには SystemLanguageModel、ブレインストームには PCC + 深い推論
  • 修飾子(modifiers)で設定を記述:
    • .model() 修飾子で使用モデルを指定 (e.g. .privateCloudCompute)
    • .reasoningLevel() 修飾子で推論の深さを指定 (e.g. .deep)
  • DynamicProfile は任意の時点では単一のアクティブな Profile に解決される
    • 条件分岐でアクティブな Profile を選ぶと、フレームワークが遷移を管理
    • 会話履歴を維持したままモデル・ツール・指示内容を切り替え可能
  • 考慮点:プライバシーの境界・モデルの能力差・コスト

15:30 – Evaluations framework

  • Evaluations — インテリジェンス機能の品質を定量的に測る新 Swift フレームワーク
    • プロンプトを調整するたびに精度を数値化
    • 変更の統計的影響を把握し、自信を持ってリリースできる設計

16:02 – The fm command line tool

  • fm CLI — macOS 27 で登場するコマンドラインから Foundation Models を使うツール
    • fm chat でターミナルからオンデバイスモデル・PCC と対話
    • シェルスクリプトに組み込んでドキュメント要約・情報抽出・コンテンツ生成も可能
    • 例:ランダムなファイル名の画像に、画像の内容に基づいた説明的なファイル名を自動生成

17:13 – Foundation Models Python SDK

  • Python エコシステム向けに Foundation Models SDK を提供
    • Swift フレームワークと同じオンデバイスモデルに直接アクセス
    • モデルが利用可能かどうかの確認・レスポンス生成が数行のコードで可能
    • prompt → 構造化レスポンスも対応

17:55 – Open source and framework utilities

  • ユーティリティパッケージの公開:LLM活用の新たな手法を探求するのに役立つ
    • 会話履歴管理用のプロファイル修飾子
    • 手続き的知識ロード用の skill API
    • Chat Completions 標準を使ったサーバーインタフェース
    • OS リリース間でも更新
  • フレームワークコアもオープンソース化
    • Swift が動く Linux サーバーなどあらゆる環境で LLM と対話可能
    • Anthropic・Google・CoreAI・MLX との連携と組み合わせて「任意のモデルをどこでも実行」が実現

FM+RAG後日談:埋め込みベクトル化の精度改善

先日、Foundation Models で RAG を試みる内容を登壇したのだが、その時のスライドに添付したソースコードに誤りがあったので、以下ブログ記事に記載していたソースコードを修正した。

もともとはベクトル化対象のテキストを、トークン分割しつつ startIndex から endIndex まで手動で動かしながら畳み込みしていたものを、シンプルに enumerateTokenVectors(in:using:)  を使うようにしたら、↑記事で記載しているイマイチ精度が出ない問題を改善することができた。

以前の実装だと、何らかの条件で文字列最後までループが到達しないことが発生していたようだ。文頭の構文しかヒットしないという現象も、この原因を考えれば納得できる。


そもそも、ここで紹介している NLContextualEmbedding + mean pooling + L2 normalization で埋め込みベクトル化し、コサイン類似度を求める手法は、すでに以下のQiita記事で同じことが解説されていた。今後実装される方はこっちを参考にした方が幸せかもしれない。(もっと早く見つけたかった、、)

iOSに組み込まれたBERTでテキスト埋め込み・ベクトル検索をオンデバイス実行する #Mac – Qiita

登壇メモ:extension DC 2025 Day1 @DeNA

久々に登壇してきたので記録。

イベントページ:extension DC 2025 Day1@DeNA

夏から取り組み始めていたFoundation Models + RAG の集大成?を発表。結果的にFM側の挙動で綺麗な結果にはならなかったが、、RAGの一翼を担う自前の検索エンジンとしてはきちんと良い結果が出たので、その実装方法を中心にシェアした。

スライドは40枚作っていたが、何度練習しても5分に収まるかは一か八かだったので、会場でトピック丸ごと(この記事の内容)省略した。そのおかげで早口ながら完走はできたのでよかった。アップロードしたスライドには、スキップした内容も復活しておいた。

Apple で開催された Foundation Models のワークショップでたくさんサポートくださった武石さんともお会いでき、FMの挙動について具体で相談させていただき追加でアドバイス頂けたので、試してみたい。

参加メモ:新しいFoundation Modelフレームワークのアプリへの導入(ワークショップ)@Apple Japan


発表後、さまざまな方にお声がけいただき、中にはこのセッションのために来たとおっしゃってくれる方や、今回発表のアプローチを自社プロダクトへ実装検討されている方も何名かいらっしゃって、今回の内容が少しでもお役に立てれば何より。

発表内で紹介した検証は、パラグラフにも満たない短文と、30という限られたドキュメント量でしか試していないので、実運用するデータ規模によっては性能限界があるかもしれない。今回触れなかった文章の細分化や、プーリングのアルゴリズムを変更するなどチューニングの余地は多く残されている。

まだ道半ばなので、今後も試行錯誤を続けていくがその過程は都度「Foundation Models」でタグ付けしていく。

https://p0dee.com/blog/tag/foundation-models

最後に、ここまでの軌跡において救世主となった武石さんのポストに改めて感謝!


会場からの帰り道になぜかYouTuberに捕まって、「あなたの人生を語ってください」的なよくある企画に巻き込まれ、日が変わるまで沖縄料理屋で飲んでた。

SwiftでRAG実装 Part 2:クエリに類似するドキュメント検索の試み

SwiftでRAG実装 Part 1:テキストをコンテキストベクトル化 の続き。

前回はドキュメントデータから埋め込み生成。今回はそこから検索クエリに類似するドキュメントを抽出してみる。先に結論を言うと、結果はいまひとつなので調整が必要そう。

今回実装する計算ロジックは以下(現状の筆者の理解であることに注意)。

  1. ドキュメントからそれぞれ埋め込み生成し、ベクトル化する
  2. ドキュメントごとのベクトル表現(D次元)を、ドキュメントの数(M個)分並べて MxD 行列を生成する(ドキュメント行列)
  3. クエリ文字列をベクトル化する。1. と同じ手法で生成しベクトル次元は一致(D次元)。(クエリベクトル)
  4. ドキュメント行列・クエリベクトル(内積)を計算する (MxD・Dx1 = Mx1)
    • つまりドキュメントごとのベクトルとクエリベクトルとの内積が結果として求まる。内積ベクトルのノルム(コサイン類似度)が1に近いほど類似度高く、0、-1に近づくほど類似度が低いと判定できる
  5. 4. の結果で得られた類似度の配列(要素数M)を降順にソート、上から任意の件数を上位ヒットとする
var docTensors: [MLTensor] = ...

// docTensors に含むドキュメントごとのクエリとの類似度を検索し、上位任意件数(maxCount)をヒット結果として返す
// (返すのは該当文書テンソルの docTensors 配列内におけるインデックス番号)
func search(query: String, maxCount: Int) async -> [Int] {
    // ドキュメントを集積した MxD 行列
    let flatteneds = tensors.map { $0.flattened() }
    let docsMat = MLTensor(stacking: flatteneds)
    // クエリベクトル Mx1 (encode関数は前回記事参照)
    guard let queryVec = try? embedder.encode(text: query, asColumn: true /*列ベクトルで出力*/) else { return [] }
    
    // ドキュメント行列・クエリベクトル (MxD・Dx1 = Mx1)
    let mulResult = docsMat.matmul(queryTensor)
    // 類似度スコア配列に変換
    let calcScores = await mulResult.shapedArray(of: Float.self).scalars
    // ソートして、トップの結果をインデックス番号として抽出
    let map = zip(Array(0..<docTensors.count), calcScores)
    let sorted = map.sorted { $0.1 > $1.1 }.prefix(maxCount)
    return sorted.map { $0.0 }
}

これでサンプルの日記データを検索したところ、肌感としてあまり精度高くなかった。

ただし、クエリの何かしらの特徴は反映していそうだった。たとえば「I watched a movie」のクエリで検索すると、映画を見た日記エントリはヒットしないが、上位ヒットは軒並み「I (動詞過去形)」のパターンで始まっていたり、更に分かりやすい例を挙げると、次のように「at a cafe」クエリに対し「At a/the」で始まるエントリが抽出されたりした。まったくの出鱈目ではなさそうだが、肝心の文章的なコンテキストは明らかに落ちているように見える。

Screenshot

追記(2025/10/01):後日性能改善できたので後々投稿するが、取り急ぎこちらの資料にソースコードを添付した。(enumerateTokenVectors を使うようにしただけ)

SwiftでRAG実装 Part 1:テキストをコンテキストベクトル化

Foundation Models のツール呼び出しに使う RAG に Spotlight を利用する試みが絶たれたので、自前で RAG 実装することにした。(本記事の作業は↓のワークショップで取り組んだもの)

経緯:参加メモ:新しいFoundation Modelフレームワークのアプリへの導入(ワークショップ)@Apple Japan

このブログでは繰り返し何度も貼っているが、Apple 武石さんのこのポストを頼みの綱に頑張る。もはやバイブル。


といってもちんぷんかんぷんなので ChatGPT に聞いてみた。

NLContextualEmbedding を使ってRAGを構築するにはどう言った実装が必要になりますか?

NLContextualEmbedding を使った “自前RAG” の実装は、だいたい次の4層で考えると設計しやすいです。

  1. 前処理(分割・メタデータ付与)
  2. 埋め込み生成(NLContextualEmbedding)
  3. ベクタ格納と近傍検索(SQLite + BLOB / CoreData / なんでも)
  4. 検索→再ランキング→プロンプト組み立て→生成(Foundation Models)

いったん前処理は省いて、まずは短い文字列を元にした埋め込み生成(コンテキストベクトル化)を試みた。ChatGPT の実装をもとに動くよう修正し、最終的にそれっぽくベクトルを得ることができた。(コメントは筆者理解の補足なので間違いあるかも)(2025/10/5 実装修正)

import Accelerate
import NaturalLanguage

let embedding: NLContextualEmbedding

/// 文字列をコンテキストベクトル化(平均プーリング+L2正規化)
func encode(text: String) throws -> [Float] {
    let result = try embedding.embeddingResult(for: text, language: overrideLanguage)

    let dim = embedding.dimension
    var sum = [Float](repeating: 0, count: dim)
    var count = 0 // トークン数

    result.enumerateTokenVectors(in: text.startIndex..<text.endIndex) { vec_double, range in
        var vec_float = [Float](repeating: 0, count: dim)
        // double → float 変換
        vDSP_vdpsp(vec_double, 1, &vec_float, 1, vDSP_Length(dim))
        // vec_float を sum に足し合わせ
        vDSP_vadd(sum, 1, vec_float, 1, &sum, 1, vDSP_Length(dim))
        // トークン数をインクリメント
        count += 1
        return true
    }

    guard count > 0 else { return [Float](repeating: 0, count: dim) }

    // 平均プーリング(トークンベクトルの総和をトークン数で平均して畳み込み)
    var inv_n = 1.0 / Float(count)
    vDSP_vsmul(sum, 1, &inv_n, &sum, 1, vDSP_Length(dim))

    return l2Normalize(sum) // L2 normalization
}

// L2 正規化(ベクトル全体を二乗和平方根で割って正規化)
private func l2Normalize(_ v: [Float]) -> [Float] {
    var vec = v
    var norm: Float = 0
    vDSP_svesq(vec, 1, &norm, vDSP_Length(vec.count))
    norm = sqrtf(norm) + 1e-12
    vDSP_vsdiv(vec, 1, &norm, &vec, 1, vDSP_Length(vec.count))
    return vec
}
print(encode(text: "Hello, world.")

[-0.028537132, -0.014218736, -0.033890422, -0.024530113, 0.009770119, -0.01361734, 0.0034657633, 0.029605899, 0.013323085, -0.005046577, ..., 0.018509272, -0.026693422, -0.6423329, -0.03437927, 0.005926335, -0.022124525, 0.03561643, -0.056179043, 0.025543895, -0.00908023, 0.0050482955, 0.028503625]

ちなみに、今回 “Hello, world.” は Hello,, , world. の3トークンに分割された。


埋め込み生成の処理をおさらいすると

  1. 文字列をもとにベクトル埋め込みを生成(NaturalLanguage.NLContextualEmbedding.embeddingResult(for:language:)
  2. 文字列をトークン(サブテキスト単位)に分割
  3. トークンごとに、トークンベクトルを抽出(NaturalLanguage.NLContextualEmbeddingResult.tokenVector(at:)
  4. すべてのトークンベクトルの平均を計算 → コンテクストベクトル
  5. コンテクストベクトルを二乗和平方根で割って正規化

これ書きながら、2-3 のステップで頑張ってループ回しているところは enumerateTokenVectors(in:using:) 使ったほう良いかも、と思った。

参考:
埋め込み層 (Embedding Layer) [自然言語処理の文脈で] | CVMLエキスパートガイド
平均プーリング(Average Pooling) | CVMLエキスパートガイド
[iOS 17] 多言語BERT埋め込みモデルのサポート


Accelerate framework に馴染みがないので、ここで使われている関数を調べてみた。

  • vDSP_vdpsp: 倍精度のベクトルを単精度のベクトルに変換
  • vDSP_vadd: ベクトル同士の和 (stride 指定可能)
    • stride: 足し合わせ時の要素飛び石数 通常は1だが、オーディオバッファからLRチャンネルを分離して取得する時(stride=2)や、イメージバッファからRGBチャンネルを分離して取得する(stride=3)時に指定
  • vDSP_vsmul: ベクトル * スカラ値 の積算 (stride 指定可能)
  • vDSP_vsdiv: ベクトル / スカラ値 の除算 (stride 指定可能)
  • vDSP_svesq: ベクトルの二乗和 sum(a^2) 、結果はスカラ値