RevComm Tech Blog

コミュニケーションを再発明し 人が人を想う社会を創る

Apple Foundation Modelsを活用したオフライン対応長文要約アプリの開発

RevCommでモバイルアプリ開発を担当している藤田と申します。本日はApple Foundation Modelsと呼ばれるオンデバイスAIを活用したオフライン対応のiOSアプリのプロトタイピングについて記載していきたいと思います。

はじめに:AIとモバイルアプリケーションの新たな可能性

近年、生成AIの急激な普及によりさまざまな領域でAIを活用したWeb, モバイルアプリが展開されています。そのほとんどはクラウドへデータ送信することでAIと連携している一方、Apple Foundation Modelsのようなデバイス上でAIを完結できるオンデバイスAIと呼ばれる技術も着実に進歩しています。

デバイス上だけでAIを活用できると、オフライン環境下やプライバシーに配慮が求められる場面でもAIを活用した機能を提供できるようになります。たとえば弊社が開発しているMiiTel RecPodと呼ばれる会議を録音して解析するアプリへの応用を考えた場合、機密性の高い経営会議のような会議の解析や電波の届かないような環境での顧客との打ち合わせのような状況でもAIを活用した機能提供を実現できる可能性が期待できます。

モバイル領域では、特に最近、先述のApple Foundation Modelsと呼ばれるオンデバイスAI処理に特化したAPIの提供を開始しており、アプリ開発者も気軽にオンデバイスAIを試すことが可能となりました。

これらを踏まえて、今回は録音後に生成される文字を要約できるiOSアプリをApple Foundation Modelsを使って実装してみることにします。

モバイルでのオンデバイスAI実装の選択肢

iOS上でオンデバイスLLMを実装する方法として、TensorFlow Lite, CoreMLを使った独自モデルの統合など複数の選択肢が存在します。これらと比較して、iOS 26以降で利用可能になったApple Foundation Modelsは、AIエンジニアと連携するなどしてモデルファイルを自前で用意する必要がなく、システムレベルで統合されているためアプリ開発者にとって簡単に扱えるという利点があります。

一方で想定されているユースケース以外での利用の場合はうまく組み込めない可能性もあるので万能ではないですが、公式ドキュメントに記載されている想定されるケースでは積極的に採用するとよさそうです。今回実装するアプリの主機能である要約もこちらに含まれています。

Foundation Modelsの制約

簡単にオンデバイスAIを機能組み込めるApple Foundation Modelsですが、いくつか制約があります。その一つがセッションあたりのトークン長制限です。Foundation Modelsでは、一度のセッションで処理できるトークン数に上限が4096までと設定されており、これには入力テキストと生成される出力の両方が含まれます。

たとえば、30分の会議音声を文字にすると数千文字以上に及ぶことが一般的です。この制約を考慮せずに長文テキストをそのまま入力すると、処理が失敗してしまい長い文章を要約する機能を実現できません。したがって、この課題を解決するために長い文章を処理するためにはそのまま文章を入力するだけでは不十分で、適切に処理できるような工夫が必要です。

Map Reduceパターンによる長文要約

先述の長文処理の手法はいくつか存在するようですが、今回はGoogleなどが提案している、シンプルかつ標準的なMap-reduceのアプローチを採用することにしました。

この手法は、大規模なドキュメント要約において実績のある標準的なパターンであり、全体の設計は主に三段階で構成されます。

  1. Chunking (分割): 元のテキストをトークン制限内に収まるサイズに分割する。
  2. Map (部分要約): 各チャンクを個別にAIに送信し、部分的な要約を生成する。
  3. Reduce (統合): 生成された複数の部分要約を一つにまとめ、全体を代表する最終要約を作成する。

このアプローチにより、Foundation Modelsのようにトークンに制約がある場合であっても、長い文章の要約が可能になります。

1. チャンク分割処理の実装

Map段階の最初のステップとして、長文テキストを適切なサイズのチャンクに分割するロジックを実装します。単純に文字数で機械的に分割すると文脈が途切れて要約の品質が低下するため、文や段落の境界を考慮した分割が必要です。

実装では、まず改行や句点で文を識別し、トークン制限の6, 7割程度を目安に文単位でチャンクを構成していきます。各チャンクには前後のチャンクとの重複部分を持たせることで、文脈の連続性を保つ工夫も施します。

この分割処理により、意味的なまとまりをある程度維持しながら処理可能なサイズにテキストを分解することができます。こちらはswiftで実装すると次のようになります。

struct Chunk { let id: Int; let text: String }

func makeChunks(from text: String, targetCharsPerChunk: Int = 2200, overlap: Int = 200) -> [Chunk] {
    guard !text.isEmpty, targetCharsPerChunk > 0 else { return [] }

    var chunks: [Chunk] = []
    var id = 0
    let end = text.endIndex
    var start = text.startIndex

    let maxRightwardExtension = 400

    while start < end {
        // The desired end position for this chunk
        let hardEnd = text.index(start,
                                 offsetBy: targetCharsPerChunk,
                                 limitedBy: end) ?? end

        var actualEnd = hardEnd

        if hardEnd < end {
            // Look a bit past hardEnd for a likely sentence boundary
            let lookaheadEnd = text.index(
                hardEnd,
                offsetBy: maxRightwardExtension,
                limitedBy: end
            ) ?? end
            
            let searchRange = text[hardEnd..<lookaheadEnd]
            
            var boundary: String.Index?

            // Prioritize Japanese period '。' as the sentence end
            if let jpPeriod = searchRange.firstIndex(of: "。") {
                boundary = jpPeriod
            }
            // Next, try to find '. ' or '.\n'
            else if let period = searchRange.firstIndex(of: ".") {
                let after = text.index(after: period)
                if after < lookaheadEnd {
                    let nextChar = text[after]
                    if nextChar == " " || nextChar.isNewline {
                        boundary = period
                    }
                }
            }
            // Lastly, look for newline
            else if let newline = searchRange.firstIndex(of: "\n") {
                boundary = newline
            }

            if let b = boundary, b > start {
                actualEnd = text.index(after: b)
            }
        }

        let slice = text[start..<actualEnd]
        guard !slice.isEmpty else { break }

        chunks.append(.init(id: id, text: String(slice)))
        id += 1

        if actualEnd >= end { break }

        // Next start is current end minus overlap
        let overlapClamped = max(0, min(overlap, slice.count - 1))
        let nextStart = text.index(
            actualEnd,
            offsetBy: -overlapClamped,
            limitedBy: text.startIndex
        ) ?? text.startIndex

        if nextStart <= start {
            start = text.index(after: start)
        } else {
            start = nextStart
        }
    }

    return chunks
}

2. 部分要約の生成 (Map処理)

分割された各チャンクに対して、Foundation Models APIを使用して部分要約を生成します。ここでは@Generableおよび@Guideというマクロを活用することで、JSON形式を明示的に指定することなく、構造化されたデータとして要約結果を取得できるようにします。@Generableおよび@Guideを使用すると、Swiftの構造体を定義するだけで、APIのレスポンスを型安全な形で直接マッピングできます。これにより、従来必要だったJSON文字列のパースやエラーハンドリングの多くを省略でき、実装が大幅に簡潔になります。また、出力に関するプロンプトの記述も省略できるためトークン消費の節約にもなります。各チャンクの要約生成では、元のテキストの重要な情報を保持しつつ簡潔にまとめるようプロンプトを設計し、生成された部分要約を配列として保持します。

今回は概要とキーポイントを持つようなデータ構造で出力結果を受け取れるよう、次のような構造体を宣言してみます。キーポイントの範囲をもう少し厳密に定めたい場合は、@Guideの中に.count(4).range(1..7)のような出力の範囲を定めることも可能です。

@Generable
struct DocSummary: Codable {
    @Guide(description: "全体の要旨。日本語で100〜200字で簡潔に。")
    var overview: String

    @Guide(description: "章や段落ごとの主要ポイント。各項目は1文50〜100字。最大5件程度。")
    var keyPoints: [String]
}

さらにこのデータ構造を出力結果として得るための部分要約の生成を次のようにInstructions、出力の設定、プロンプトを設定することで実現します。トークン長の制約を考慮して、部分要約のたびにセッションを新たに作りなおしています。なお、temperatureはLLMの出力を調整するためのパラメータで、温度を高く設定するとより多様で創造的な出力となり、低くするとより一貫性の高い出力となります。今回は要約というある程度出力の方向性が決まったタスクのためやや低めの温度に設定しています。

func summarize(chunk: Chunk,temperature: Double, maxOutput: Int = 400) async throws -> DocSummary {
    let instructions = """
    テキストの要約者として振る舞ってください。
    具体的な事実・数字・固有名詞を落とさないようにまとめます。
    """

    let session = LanguageModelSession(instructions: instructions)

    let options = GenerationOptions(
        temperature: min(0.5, max(0.2, temperature)),
        maximumResponseTokens: maxOutput
    )

    let prompt = """
    次の文章を要約してください。

    - overview: 全体の要旨
    - keyPoints: 具体的なポイント

    テキスト:
    \(chunk.text)
    """
    
    let summary = try await session.respond(to: prompt, generating: DocSummary.self, options: options)
    return summary.content
}

3. 統合要約の生成(Reduce処理)

すべてのチャンクから部分要約が生成されたら、Reduce段階に進みます。複数の部分要約を結合して一つのテキストとし、再度Foundation Modelsに送信して最終的な全体要約を生成します。この段階では、部分要約間の重複を排除し、文書全体の主要なポイントを抽出するようプロンプトを調整します。部分要約の数が多い場合には、階層的なアプローチも検討できますが、今回はプロトタイプ的な実装のためシンプルな方法で進めます。Reduce段階でも@Generableを使用して、最終要約を構造化されたデータとして取得し、アプリケーションのUIに表示する準備を整えます。

今回は次のように部分要約をひとつにまとめて全体要約を生成する処理を実現します。

func reduce(_ parts: [DocSummary], temperature: Double, maxOutput: Int = 600 ) async throws -> DocSummary {
    let instructions = """
    あなたはプロの要約者です。
    与えられた複数の部分要約を読み、重複や類似内容を統合して1つの要約を作成してください。
    事実・固有名詞・数字は残し、抽象的・汎用的な文は減らしてください。
    """

    let session = LanguageModelSession(instructions: instructions)

    // Output tokens are set slightly higher for the reduce proces
    let adjustedMaxOutput: Int = {
        if maxOutput <= 0 { return 400 }
        return min(maxOutput, 700)
    }()

    let adjustedTemp = min(0.8, max(0.3, temperature))
    let options = GenerationOptions(
        temperature: adjustedTemp,
        maximumResponseTokens: adjustedMaxOutput
    )

    // Throttle the number of input parts to prevent the prompt from becoming too large.
    let maxPartsToShow = min(
        parts.count,
        12
    )
    let partsToMerge = Array(parts.prefix(maxPartsToShow))

    let merged = partsToMerge.enumerated().map { i, s in
        """
        [\(i + 1)] \(s.overview)
        Points: \(s.keyPoints.prefix(5).joined(separator: " | "))
        """
    }.joined(separator: "\n\n")

    let reducePrompt = """
    次の \(partsToMerge.count) 個のテキストサマリを、1つのサマリに統合してください。

    - 重複・似た内容のポイントは1つに統合
    - 具体的な事実・数字・固有名詞はできる限り残す
    - 最大 5 個程度の keyPoints に絞る

    \(merged)
    """

    let response = try await session.respond(
        to: reducePrompt,
        generating: DocSummary.self,
        options: options
    )

    let result = response.content

    return result
}

エラーハンドリング

Foundation Models APIの呼び出しは、トークン長の制限を超える入力が行われる場合以外にも、デバイスがそもそも対応していない、指定したデータ構造に出力する場合に内部でデシリアライズに失敗するなどさまざまな理由により失敗する可能性があります。また、失敗する原因の一部はFoundation Modelsが抱えている不具合といわれており、これらを完全にゼロにすることはLLMの性質上おそらく難しいです。

したがって今回は、次のように特に頻繁に発生する、内部でのデータ構造に変換する際に生じるエラーに対してのフォールバック処理として、失敗したら制約を緩めたプロンプトでリトライすることで要約処理を続行できるようにしています。今回はプロトタイピングなので簡易的な対応にとどめていますが、実用的なアプリを作る場合はもう少し堅牢な処理が必要になると思われます。

do {
    let summary = try await session.respond(
        to: prompt,
        generating: DocSummary.self,
        options: options
    )
    return summary.content
} catch LanguageModelSession.GenerationError.decodingFailure(let llmContext) {
    // Retry with simplified prompt
    let fallbackPrompt = """
    次の文章を要約してください。

    - overview: 全体の要旨
    - keyPoints: 具体的なポイント

    テキスト:
    \(chunk.text)
    """
    let fallbackOptions = GenerationOptions(
        temperature: 0.2,
        maximumResponseTokens: 300
    )
    let summary = try await session.respond(
        to: fallbackPrompt,
        generating: DocSummary.self,
        options: fallbackOptions
    )
    return summary.content
} catch {
    throw error
}

完成したアプリ

app-summary-ss

完成したアプリはこちらです。画面上部のテキストフィールドに文字を入力して要約ボタンを押下すると、文字列が長い場合は先述の処理が実行され、要約結果が画面下部に表示されます。

完成したプロトタイプアプリを使って、実際に長い文章を要約させると、5000字くらいで10秒くらい、15000字くらいで30秒から60秒くらいで結果が得られました。30分程度の会議音声を文字起こしすると15000字前後くらいになることもあるので、ある程度実用に耐えうる処理時間であることがわかりました。また、機内モードなどオフライン環境下に設定して要約を実行しても問題なく処理できることも確認できました。精度は最近の高性能なモデルと比較すると劣りますが、主要なポイントは抽出されており、こちらも実用に耐えうるレベルと考えます。

まとめ

本記事では、Apple Foundation Modelsと呼ばれるオンデバイスAIフレームワークを用いて、オフラインでも動作する長文要約アプリをiOSで実現する方法を紹介しました。Apple Foundation Modelsは比較的新しい技術で知見が多くないのか、普段の開発と比較してAIが正しくない回答をすることが多く、実装過程では想定より試行錯誤が必要でした。

実務で組み込むにはエラーハンドリングなどより安定性や精度、パフォーマンスを高める工夫が必要かもしれませんが、今回のプロトタイピングを通してよりユーザー体験を高められる機能提供を目指したいと思います。この取り組みが、同様の課題に取り組む開発者の参考になれば幸いです。