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

大規模なテキストベースである日記と、ローカルLLMとを組み合わせて何かできないか、Foundation Models で実験する試みの、前回の続き。

Spotlight に日記の文章をインデクシングできたとして、その情報をうまいこと Foundation Models の tool calling で引き出すことができるのか。仮に「散髪を最後にしたのはいつ?」という質問を想定して、モックデータをもとに検証してみた。

  1. Foundation Models の tool calling の挙動を確認
    • タイミング、回数、使うキーワード、日本語でも可能か など)
  2. ツールから得た日記本文を回答に活用できるか
    • 本文のように長い文章を読んで解釈してくれるか/一問一答でピンポイントな情報がツールから得られる必要があるか
    • 複数日分の本文が返っても、区別して情報処理できるか

ツールから得られる情報の処理の仕方が知りたいので、今回は一旦常に同じデータを返すようにする。

import FoundationModels
import Foundation

struct DiarySearchTool: Tool {
    let name = "searchDiaryDatabase"
    let description = "検索キーワードで日記のエントリーを検索する"
    
    @Generable
    struct Arguments {
        @Guide(description: "検索キーワード")
        var queries: [String]
    }
    
    
    struct Entry {
        var date: Date
        var content: String
    }
    
    func call(arguments: Arguments) async throws -> [String] {
        // Foundation Modelsがtool callingしたか/どんなクエリで検索しようとしたか出力
        print("### augumantes: \(arguments)") 
        
        let entries = dummyEntries // 常に同じモックデータを返す
        let formattedEntries = entries.map {
            "日付\($0.date) 本文: \($0.content)"
        }
        return formattedEntries
    }
}

モックデータとして3日分、それぞれ以下を取り入れるよう ChatGPT に考えさせた。これは、単純に「散髪」というキーワードに反応するだけでなく、日記の中身をモデルが理解した上でレスポンスするかを確かめるため。

1. 散髪に行こうか迷っている
2. 散髪に行った
3. 散髪に行ったことを友達に気がついてもらえた

let dummyEntries: [Entry] = [
    .init(date: "2025-07-18 20:13:00".toDate()!,
          content:
              """
              鏡の前で前髪とにらめっこ。伸ばすか切るか、天秤は揺れっぱなし。
              散髪に行こうか迷っているうちにコーヒーは冷め、予定は延び、前髪だけが視界を奪う。
              雨予報も背中を押さず、今日は見送るか、どうするか。
              """),
    .init(date: "2025-06-09 17:02:00".toDate()!,
          content:
              """
              雲の切れ間みたいに決心が差した午後、散髪に行った。
              軽くなった頭皮に風が通る。落ちた髪の山を見下ろし、スマホの自撮りで角度を研究。
              鏡越しの自分と握手。担当さんのハサミとシャンプーの柑橘の香り。肩の荷まで落ちた気がする。
              """),
    .init(date: "2025-04-08 21:54:00".toDate()!,
          content:
              """
              駅前で友達と合流。開口一番「なんか爽やか!」と笑われ、
              散髪に行ったことを友達に気がついてもらえた。前髪の軽さだけでなく、
              歩幅まで半歩分広がった気がする。他愛ない近況を話しながら、帰り道の影まで軽くなった気分。
              """),
]

なんかムズムズする日記、、笑
プロンプトを与えて、いよいよ Foundation Models に質問してみる。

let session = LanguageModelSession(
    tools: [DiarySearchTool()],
    instructions:
        "ユーザーは自分の日記について質問を投げかけます。あなたは関連するキーワードを使用して日記のデータを検索し、その質問に回答します。"
)

予想以上にうまく答えられている。内容を理解した上で答えていそうなことはわかった。

以下ログを見ると、モデルがツールに渡している検索キーワードが、質問文そのままだったりしてイマイチなので、ここはプロンプトなどで調整の余地がありそう。(あるいは Core Spotlight のセマンティックサーチが吸収してくれるかも?)

Send message: 最後に髪を切ったのはいつですか?
### augumantes: Arguments(queries: ["髪を切ったのはいつですか?"])
PartiallyGenerated(id: FoundationModels.GenerationID(value: ..., text: Optional("最後に髪を切ったのは2025年6月9日の午後です。"))

Send message: 散髪を友達に気づいてもらえたのはいつですか?
### augumantes: Arguments(queries: ["散髪", "友達に気づいてもらえた"])
PartiallyGenerated(id: FoundationModels.GenerationID(value: ..., text: Optional("散髪を友達に気づいてもらったのは日付2025-07-18です。"))

Send message: 散髪を迷っていたのはいつですか?
### augumantes: Arguments(queries: ["散髪を迷っていたのはいつですか?"])
PartiallyGenerated(id: FoundationModels.GenerationID(value: ..., text: Optional("散髪を迷っていたのは2025年4月8日のことです。"))

最後に、ここまででいくつか失敗があったのでまとめておく。

Tool calling 経由で大規模な文章が渡るとコンテクストウィンドウ超過になる

予想はしていたがやはりそうだった。初めは、筆者の日記の実データを用いて検証したのだが、1日分だけで2千〜5千文字近くあり、余裕で “Exeeded models context window size” エラーとなった。

これは、たとえば長文の日記本文を小分けにして Spotlight にインデクシングさせることで解決するかもしれない。

指示文が不十分だとうまくtool calling してくれない

当初、インストラクション、ツール説明、プロンプトは以下のように記述していた。

struct DiarySearchTool: Tool {
    let name = "searchDiaryDatabase"
    let description = "日記のエントリを条件に従って検索します"


    @Generable
    struct Arguments {
        @Guide(description: "検索キーワード")
        var queries: [String]
    }
...

let session = LanguageModelSession(
    tools: [DiarySearchTool()],
    instructions: "あなたは日記データをもとにユーザーからの質問に答える役をします。"
)

しかし以下の通り、珍紛漢紛な返答ばかりで、そもそもツールを呼び出していないことが判明。最終的に、`searchDiaryDatabase` というツールを認識しているか、という直接的な質問をして、はじめて「AI」という謎キーワードでツールを呼び出すようになった。

Send message: 最後に散髪に行ったのはいつ?
... // ←ツールを呼び出していない
PartiallyGenerated(id: FoundationModels.GenerationID(value: ..., text: Optional("最後に散髪に行ったのは2024年7月15日のことです。"))

Send message: 日記から最後に散髪に行った日のエントリーを検索して、その日付を教えてください。
... // ←ツールを呼び出していない
PartiallyGenerated(id: FoundationModels.GenerationID(value: ..., text: Optional("検索結果は以下の通りです。最後に散髪に行った日に関するエントリーは存在しません。"))

Send message: searchDiaryDatabase というツールを使って、最後に散髪へ行った日を検索して、その日付を教えてください。
... // ←ツールを呼び出していない
PartiallyGenerated(id: FoundationModels.GenerationID(value: "E042ADEE-3C46-424C-9B2F-DD572136FC2B"), text: Optional("散髪に行く日についての情報は取得できませんでした。"))

Send message: searchDiaryDatabase というツールを認識していますか?
### augumantes: Arguments(queries: ["AI"]) // ←ツールを呼び出した
...
PartiallyGenerated(id: FoundationModels.GenerationID(value: ..., text: Optional("以下に日記のエントリを3つ返します。\n1. 日付2025-07-18 20:13:00 +0000 本文: 鏡の前で前髪とにらめっこ。伸ばすか切るか、天秤は揺れっぱなし。散髪に行こうか迷っているうちにコーヒーは冷め、予定は延び、前髪だけが視界を奪う。雨予報も背中を押さず、今日は見送るか、どうするか。\n2. 日付2025-06-09 17:02:00 +0000 本文: 雲の切れ間みたいに決心が差した午後、散髪に行った。軽くなった頭皮に風が通る。落ちた髪の山を見下ろし、スマホの自撮りで角度を研究。鏡越しの自分と握手。担当さんのハサミとシャンプーの柑橘の香り。肩の荷まで落ちた気がする。\n3. 日付2025-06-08 21:54:00 +0000 本文: 駅前で友達と合流。開口一番「なんか爽やか!」と笑われ、散髪に行ったことを友達に気がついてもらえた。前髪の軽さだけでなく、歩幅まで半歩分広がった気がする。他愛ない近況を話しながら、帰り道の影まで軽くなった気分。"))

インストラクション、プロンプトを英語に直したことで期待通りにツールを呼び出すようになったので、日本語と英語の違いか、と思ったがその後この英語をそのまま日本語に訳して適用したところ、問題なくレスポンスしてくれるようになった。やはりプロンプトの設計が重要。