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) 、結果はスカラ値

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

アップルジャパンで開かれた Foundation Models のワークショップに参加してきた。結果から言うと、知りたいこと、気になっていたことはすべて明快にでき、お話もコーディングもたくさんできて、とても充実した時間となった。

参加者が少なかったおかげで、心置きなくテクノロジーエヴァンジェリスト武石さんとお話しできたのが良かった。Foundation Models に限らず、何をお尋ねしても的確な回答をくださったのがとても心強く、今後の取り組み方にも自信を持つことができた。自学自習だけだとこうはいかない。

ワークショップ:新しいFoundation Modelフレームワークのアプリへの導入

ワークショップとはいうものの、中身は事前に告知されていた通りにもくもく会で、冒頭5〜10分 Foundation Models framework に関するイントロがあったのち、3時間ほぼほぼ質問と作業時間に費やすことができた。

このブログで長々したためている通り、筆者は「日記を Spotlight にインデクスし Foundation Models に検索させ、対話に使う」ことをテーマに取り組んでおり、そのプロジェクトを持ち込んだ。課題が山積していて、相談はおおきく以下があった。

参考:メインで対応くださった武石さんのツイート


冒頭、いくつか tips の紹介があった。(メモしている範囲で)

  • Languages
    • デフォルトのレスポンス言語は、入力(プロンプト)の言語によって決定される
      • tool calling に英語が混ざっていたら英語になる可能性があるので、instruction で言語を指示すると良い
    • プロンプトには LocalizedStringLocale.current を instruction 指定に使うのを推奨(ユーザーの言語環境に応じられる)
  • Tool calling
    • ツールを強制的に呼び出す際は、instruction に強い表現を使うこと(プロンプトテクニック)
      • ALWAYS USE *** tool to find…
      • You MUST USE *** tool
  • Handling limited token
    • 4096 token というトークン上限
      • 日本語は、CJK は1文字1token(英語は3-4文字1token)
    • トークン消費を見るためのAPIはない
      • やりとりの他にも、生成された結果もセッション内でトークンに含めて管理しているのでややこしい
      • exeededContextWindowSize エラーを捕捉して新しいセッションを作る
      • GenerationOptions.maximumResponseTokens でコントロールする
      • 長い文章を扱う場合、NLTokenizer でチャンクを作る(Natural Language framework)、パラグラフベースで文章を切ることが可能

質疑で新たに知ることができたことのメモ。事前に実験したこと、考えていたことをもとにディスカッションくださった。

  • Core Spotlight のセマンティック検索挙動について
    • 部分一致的な挙動になるのが現状の性能限界
    • Siri の改善が追いついていない
    • WWDC24 セッションで言及されたサンプルコードも現状存在しない
  • ツール呼び出しをしてくれないケースがある
    • 指示文に “must use the tool (“SearchDiaryTool”)” と書いていたところを、よりシンプルに “must use the SearchDiaryTool” とするだけで変わるかも
    • 肌感としてツールの名称定義を、動詞-名詞 (SearchDiary, GetXXX) のようにするなどすると結果が変わったりする
    • ↑ など、指示文の細かい書き方のチューニングが効果ある
    • (ツール呼び出しに限らず)日本語よりも英語の方がパフォーマンスが良い
  • ツール呼び出しの制限
    • タイムアウトは特に明言されていなく、ない認識
    • だが、ツール呼び出し周りの情報がトークンウィンドウを溢れさせて終了することはある
      • セッション自体の指示文やプロンプトだけでなく、ツール定義(指示文など)もウィンドウを占有する。複数ツールを与えるとその分オーバーヘッドが増大する
      • ツール呼び出しでツールから取得した情報もすべて、最終的なレスポンスに関わらずトークンウィンドウを占有する
    • Foundation Models にツール呼び出しをさせるよりも、Foundation Models 外で情報を事前に取得し、プロンプトとして与える方が良い(ツール呼び出しの不確実性 < プロンプトで与えた情報が使われる確実性)
      • Foundation Models は自然言語的に回答を整形するのに使うという割り切りも検討
  • RAG 実装
    • ↑ 武石さんツイートの技術的な噛み砕きや、サンプルコードをもとにした解説
    • チャンク化→ベクトル化したものを、メタデータ付きの構造体としてラップしてあげ照合することで、より豊かな検索体験が作れそう
    • ベクトル化した情報は必ずしも SQL や CoreData に永続化する必要はない(規模によってはオンメモリでも十分)
  • そのほか
    • Foundation Models の得意分野として、結局、サマリや抽出が得意
      • Writing Tools も内部的に同じモデルを使っている
      • Writing Tools では、RAGの自前実装で行わなければいけないチャンク化など内部的に勝手にやってくれるのでラク

前半は上記の質疑を中心に、後半は RAG の実装にチャレンジしていて、つまりどころはその都度質問させていただいた。その際、Apple 社内で流通しているサンプルコードを参考に見せてくださったのは、実現方法が一目瞭然でとても嬉しかった。

ということで今日いただいた情報をもとに、Core Spotlight にはいったん見切りをつけて RAG を自前実装する方向に舵きりしたいと思う。

Core Spotlight:セマンティック検索を試みる – その2 (CSUserQuery)

前回の続き:Core Spotlight:セマンティック検索を試みる – その1

Core Spotlight で自前でインデクシングしたコンテンツを検索する場合、CSSearchQuery を使って検索クエリを作成する場合、専用のクエリ文字列を複雑なルールに従って構築する必要がある。

/// Using CSSearchQuery

// Build a query with keywords
func buildQuery(with keywords: [String]) -> String {
    // refer to: 
    // Searching for information in your app | Apple Developer Documentation
    // https://developer.apple.com/documentation/corespotlight/searching-for-information-in-your-app
    let perWordQueries: [String] = keywords.map { word in
        let v = "\"*\(word)*\"c" //大文字小文字を無視
        return "(title == \(v)) || (textContent == \(v)))"
    }
    return perWordQueries.count > 1
        ? "(" + perWordClauses.joined(separator: ") && (") + ")"
        : perWordClauses.first ?? ""
}

let searchQuery = buildQuery(with: keywords)
let context = CSSearchQueryContext()
let query = CSSearchQuery(
    queryString: searchQuery,
    queryContext: context
)
// Event handling
query.foundItemsHandler = { items in ...
query.completionHandler = { error in ...

// Start searching
query.start() 

一方、ユーザーが入力した文字列を元にセマンティックに検索したいばあいは、CSUserQuery が便利。入力された文字列をそのまま、userQueryString としてクエリのイニシャライザに渡すことができる。

/// Using CSUserQuery

let context = CSUserQueryContext()
let query = CSUserQuery(
    userQueryString: searchQuery,
    userQueryContext: context
)
for try await element in query.responses {
    switch(element) {
    case .item(let item): 
        // 検索結果に対する処理
    case .suggestion(let suggestion):
        //サジェスト候補に対する処理
    @unknown default: break
    }
}

しかし、検索結果はとてもセマンティックとは言い難く、部分一致的な挙動しかしてくれない。


Core Spotlight に関する素晴らしい記事を発見し(しかも投稿が2週間前)、これを参考にしている。

Core Spotlight integration for Spotlight and internal app search


追記(2025/09/09)

Core Spotlight のセマンティック検索が部分一致的な挙動しかしないのは、実装の問題ではなく、Siri の性能限界であると Apple の方が教えてくださった。セマンティック検索のセッション(WWDC24)で実演され、また言及もされているデモアプリのサンプルコードについても、いくら探しても見つからなかったが、これもそもそも公開されていないということだった。一件落着(?)。

Core Spotlight にコンテンツをインデックスする

日記エントリを Spotlight にインデックスさせたい。
(経緯:WWDC24:Support semantic search with Core Spotlight – long forgotten now…

// インデックス作成
func searchableItem(from entry: DiaryEntry) -> CSSearchableItem {
    let attributeSet = CSSearchableItemAttributeSet(contentType: UTType.content)
    attributeSet.contentType = UTType.text.identifier
    attributeSet.title = entry.title
    attributeSet.textContent = entry.content
    attributeSet.recordingDate = entry.date
    
    let item = CSSearchableItem(
        uniqueIdentifier: "\(entry.date.timeIntervalSince1970)",
        domainIdentifier: domainIdentifier,
        attributeSet: attributeSet
    )
    return item
}

let index = CSSearchableIndex(name: "example")
let items = diaryEntries.map { searchableItem(from: $0) }
do {
    try await index.indexSearchableItems(items)
} catch {
    // error handling
}
// 検索
func buildQuery(with keywords: [String]) -> String {
    // refer to: 
    // Searching for information in your app | Apple Developer Documentation
    // https://developer.apple.com/documentation/corespotlight/searching-for-information-in-your-app
    let perWordQueries: [String] = keywords.map { word in
        let v = "\"*\(word)*\"c" //大文字小文字を無視
        return "(title == \(v)) || (textContent == \(v)))"
    }
    return perWordQueries.count > 1
        ? "(" + perWordClauses.joined(separator: ") && (") + ")"
        : perWordClauses.first ?? ""
}

let searchQuery = buildQuery(with: keywords)
let context = CSSearchQueryContext()
context.fetchAttributes = ["title", "textContent"]
let query = CSSearchQuery(
    queryString: searchQuery,
    queryContext: context
)
var results: [CSSearchableItem] = []
query.foundItemsHandler = { items in
    // `items` is hit results
    results += items
}
query.completionHandler = { error in
    // completion handling
    print(results)
}

これでアプリ内からインデクスしたコンテンツに対して Spotlight 検索し、ヒットすることが確認できた。

参考
Adding your app’s content to Spotlight indexes | Apple Developer Documentation
Searching for information in your app | Apple Developer Documentation

Foundation Models の RAG に Core Spotlight を活用できそうか超簡単な実験

WWDC24:Support semantic search with Core Spotlight で記したように、日記データを処理するオンデバイス LLM の実験のために Foundation Models の活用を検討しており、エントリ検索のための RAG として Core Spotlight が使えないか考えている。

ユーザー質問を受けた Foundation Models 側で、適切なクエリを生成できることが分かれば何かは作りだせそう。しかし以前の反省のとおりいきなりモデルの実装をし始めるのは悪手。まずは Foundation Models に対し自然言語でやりとりをし、狙う用途を満たす知識や能力があるかを先に検証するべき。

ということで、次のプロンプトを投げてみた。

あなたはCore Spotlightにインデックスされた日記データをもとにユーザーからの質問に答える役をします。Core Spotlightから情報を引き出すためには、ユーザーの質問を解釈し、Core Spotlightに投げるクエリを考える必要があります。ユーザーから「最後に散髪したのはいつ?」と尋ねられたら、どんなクエリを投げますか?

返ってきた答えは以下。

「最後に散髪したのはいつ?」という質問に対して、Core Spotlightに投げるクエリを考えるには、以下のようなアプローチが考えられます。このクエリは、ユーザーの行動履歴や、関連するキーワードを考慮して作成されます。

```plaintext
Core Spotlight Query:

- Type: Query
- Language: Natural Language
- Query: 最後に散髪したのはいつ?

- Context:
  - Keywords: 散髪、カット、美容室、ヘアスタイル、ヘアサロン
  - Date Range: 最近までの履歴を考慮

- Filters:
  - Category: Personal Care
  - Action: Haircut
  - Location: Salon/Barber Shop
  - Date: Last occurrence

- Constraints:
  - Language: Japanese
  - Language Preference: Japanese

```

このクエリは、ユーザーの行動履歴を基に「散髪」に関連するキーワードを特定し、最近までの履歴を考慮して「最後に散髪したのはいつ?」という質問に応答します。また、日付範囲を最近のものに限定し、具体的な場所やアクションを強調することで、より正確な情報 retrieval を促進します。

Context / Filters / Constraints をクエリ設計に含めているあたり、同じ Apple の技術である Core Spotlight のコンセプトを理解できているのかは怪しいが、少なくとも質問内容から的確にキーワード抽出ができそうなことは分かった。(まあここはさすがにLLMの得意分野だろう、、)

WWDC24:Support semantic search with Core Spotlight

日記データとローカルLLMを組み合わせて何かできないか考えているが、Foundation Models の adapter training は日々更新される情報の追加学習には向いていない(し、そもそもユーザー生成データを学習させることもできない)。文書データと相性が良さそうな RAG を iOS でうまいこと活用できないか ChatGPT に壁打ちしていたら、Core Spotlight によるセマンティック検索が有効と教えてくれたので、去年の WWDC セッションビデオを見てみた。

完全一致でなくともある程度文脈を汲み取ったコンテンツ検索ができるようで、さすが Spotlight 技術なのでバッチ更新も可能だし、Core Spotlight でアプリ内から検索処理が行えるのなら、Foundation Models に tool calling 経由で自律的に検索させることができるだろうか?


0:00 – Introduction

  • CoreSpotlight フレームワークでアプリ内に強力な検索体験を構築する新しい API を紹介
  • セマンティック検索により、意味的に類似した検索語でも適切な結果を取得可能
  • 完全にローカルでプライベートな検索インデックスを提供
  • ジャーナルのエントリ検索アプリを例に、検索体験の構築方法を説明

1:37 – Searchable content

  • 検索可能なコンテンツを設計 → 検索インデックスを構築 → Spotlight に提供
  • CSSearchableItemuniqueIdentifier, domainIdentifier, attributeSet を設定
  • CSSearchableAttributeSetcontentType, title, textContent, contentURL を設定し、セマンティック検索に最適化
    • アイテムが画像、動画アセットの場合は、アセットへのパスを contentURL に指定
    • 添付ファイルやWebコンテンツへの参照は、独自のコンテンツタイプと属性をインデクスに提供
    • ソースアイテムとの関係に relateUniqueIdentifier を指定
  • バッチインデックス作成と client state で効率的な Spotlight への提供を実現
  • item.isUpdate を使い、すでにインデックス済みの属性上書きを防ぎ、必要なものに限り提供可能

4:10 – Migration and Recovery

  • 移行と復元:一貫性を維持するために重要
  • なんらかの問題で移行と復元を要する場合、Spotlight がアプリに際インデックス化を要求
  • CoreSpotlight Delegate extension で Spotlight からの再インデックス要求に対応
    • Spotlight はアプリとは別にこの Extension を呼び出すことができる(デバイスがアイドル状態の時にバックグラウンドで再インデックスを実行)
  • mdutil ツールでデバッグとテストが可能

6:56 – Results and suggestions

  • CSUserQueryContext でクエリを設定し、セマンティック検索を有効化
  • ランク付けされた結果とサジェスションを取得、最大結果数も指定可能
  • メタデータ構文でフィルタクエリを構築し、結果をカスタマイズ
    • 検索結果をUIにに合わせる最適な手法(e.g. 画像のみ表示時は画像のみとするようクエリ指定)
  • 検索バーからのクエリ文字列とクエリコンテクストから CSUserQuery を生成
  • 非同期バッチで結果を取得し、compareByRank でソート
    • 候補(CSSuggestion)は入力文字列の補完として返却
  • セマンティック検索はプロセスに必要な学習モデルのダウンロードが必要
    • ロード・アンロードでメモリ容量を節約(CSUserQuery.prepare, .prepareWithProtectionClasses を検索UI表示時に実行)

9:18 – Ranking

  • エンゲージメントと鮮度のシグナルでランキングを改善
  • lastUsedDate プロパティでコンテンツの使用頻度を記録
  • ユーザーの検索行動(閲覧、スクロール、詳細表示)を追跡
  • 適応的なランキング体験でユーザーが最も関心を持つコンテンツを優先表示

10:17 – Wrap-up

  • CoreSpotlight で完全にプライベートなオンデバイス検索インデックスを構築
  • セマンティック検索により、より強力で直感的な検索体験を提供
  • 複数プラットフォームとロケールに対応した多様なコンテンツ検索を実現
  • App Intents や CoreData との統合でさらなる検索機能を拡張