Gemma 2Bで実用的なローカルRAGアプリを作るためのチューニングと「時間クエリ」対策

はじめに
最近、プライバシー保護のため、すべてのデータをクラウドに送信せず、デバイス内で完結させるエッジAI(ローカルAI)に関心を持っている。
その取り組みとして、Android端末の内部ストレージにある複数のMarkdownファイルを読み込んで、端末内で質問に答える**ノートアプリ「mini-brain」**を開発している。
しかし、実機で動作を確認したところ、質問に関連しないファイルを検索してしまったり、回答を誤ったりする問題が生じた。 端末内で動作する軽量LLMであるGemma 2Bは、クラウド上の大規模なLLMと比べてパラメータ数が少なく、複雑なコンテキストを理解する能力が限られているからだ。
そこで、ローカルでのRAG(検索拡張生成)の精度向上と、時間や期間に関するクエリへの対策を行った。 これにより、実用的な水準まで回答精度を向上させることができた。 本稿では、そのチューニング方法を解説する。
システム構成とモデルスペック
本アプリで想定しているモデルと開発環境の仕様は以下の通りである。
| 項目 | スペック / 採用技術 | 役割・制約 |
|---|---|---|
| ベースOS | Android | 実機での動作確認を重視 |
| オンデバイスLLM | Gemma 2B (LiteRT-LM) | プライバシー優先、単一スレッドで動作するため処理時間がかかる |
| 埋め込みモデル | USE (Universal Sentence Encoder) Multilingual | ベクトル検索用。コサイン類似度の値が低く算出されやすい特性がある |
| 検索エンジン | SQLite FTS4 + ベクトルDB | キーワード検索(BM25)と意味検索(Vector)のハイブリッド |
| エージェント手法 | Search First フロー + ReActフォールバック | 処理の高速化と、回答不能時の探索を両立 |
1. エージェント指向(ReAct)の採用と失敗
開発の初期段階では、大規模なLLMを用いた自律型エージェントのように、LLMに検索キーワードを自律的に考えさせながらファイルを探索させるReAct(Reasoning and Acting)ループを採用していた。
しかし、この構成はGemma 2Bでは十分に機能しなかった。
Gemma 2Bのような軽量モデルは、次に検索すべき語彙を推論する能力が不足している。 また、エージェントの探索ループを数往復させるだけで、単一スレッド動作による数十秒の遅延が生じるため、実用的な応答速度を維持できなかった。
そこで、軽量LLMに探索を任せる構成を避け、検索処理はルールベースのパイプラインで完結させた上で、LLMには最後の回答生成のみを任せるSearch First構造へ移行した。 もしLLMの推論によるループ処理をそのまま維持していれば、デバイスの計算資源を浪費し続け、ユーザーの待ち時間を引き延ばす結果になっていただろう。
2. 検索精度を向上させる3つのチューニング
しかし、検索をルールベースのパイプラインに移行した初期状態では、適合するファイルが十分にヒットしなかった。 そこで、以下の3つのパラメータ調整を実施した。
① 類似度しきい値の緩和(0.45から0.30への変更)
本アプリで採用した多言語文埋め込みモデル(USE Multilingual)は、コサイン類似度の算出値が低めに出る傾向がある。
そのため、初期設定のしきい値である 0.45 のままでは、適合度の高いテキストチャンクがフィルタリング処理によって除外されていた。
しきい値を 0.30 まで引き下げることで、検索漏れ(再現率)を改善した。
② チャンクのオーバーラップの拡張(50文字から150〜200文字への変更)
1チャンクを800文字とした場合、重複部分であるオーバーラップが50文字(約6%)では、文脈の境界でテキストが分断された際に文脈情報が失われやすい。 この重複部分を全体の約20%に相当する150〜200文字に増やすことで、文脈情報が欠落する頻度を抑えることができた。
③ キーワードとベクトルによるハイブリッド並行検索
しかし、ベクトル検索(意味的類似度)だけでは、固有名詞や特定の文字列の検索に対応しきれない。 なぜなら、ベクトル埋め込みは全体の概念的な類似度を測るのには適しているが、固有名詞の完全一致を厳密に区別できないからだ。 そこで、SQLiteのFTS4を利用したキーワード検索(BM25)、ファイル名やタグに基づくメタデータ検索、およびベクトル検索の3つを並行して実行し、最後にReciprocal Rank Fusion(RRF)を用いて順位を統合する仕組みを構築した。 さらに、日本語の1文字検索にも対応させるため、SQLiteのインデックス作成時に1文字単位で分割するユニグラムを追加した。
3. 時間表現を含むクエリへの対策
RAGの検索を最適化した結果、固有名詞や話題を指定するトピック検索の精度は向上した。 しかし、日記を対象とする場合、時間的な制約を含む質問に対して適合するファイルを検索できない問題が残った。
ユーザー:「今年の正月って何してたっけ?」 Gemma 2B:「知識ベースには今年の正月に関する情報が含まれていません」
実際には、2026-01-01.md などの日記ファイルが存在するにもかかわらず、検索システムがそれを抽出できない。
この問題の原因は、二点に集約される。
第一に、ベクトル検索が時間的な順序や前後関係を表現できないことである。
第二に、Gemma 2Bが「今年の正月」や「去年の冬」といった曖昧な時間表現を、データベース検索に必要な具体的な日付範囲(2026-01-01 から 2026-01-07 まで)へと適切に変換する推論能力を備えていないことである。
この時間表現を含むクエリに対処するため、以下の3段階の対策を実装した。
ステップA:日付解析モジュール「DateResolver」の実装
まず、LLMによる日付の推論処理を排除し、アプリケーション側(Kotlin)でクエリ内の時間表現を正規表現によって解釈するDateResolverオブジェクトを導入した。
// 「昨日」「正月」「去年の冬」などを検出し、具体的な日付/日付範囲に変換する
object DateResolver {
private val MONTH_DAY_RE = Regex("""(\d{1,2})月(\d{1,2})日""")
private val MONTH_ONLY_RE = Regex("""(\d{1,2})月""")
fun resolveToDateStrings(question: String): List<String> {
val today = LocalDate.now()
val isPastYear = question.contains("去年") || question.contains("昨年")
return when {
question.contains("正月") -> {
val year = if (isPastYear) today.year - 1 else today.year
(1..7).map { LocalDate.of(year, 1, it).toString() } // 1/1〜1/7の範囲
}
question.contains("年末") -> {
// 上半期(6月以前)に「年末」と言ったら去年の年末を指す可能性が高いという推定ルール
val year = if (isPastYear || today.monthValue <= 6) today.year - 1 else today.year
(25..31).map { LocalDate.of(year, 12, it).toString() }
}
// 「1月1日」や「2月(月全体)」のパターンの解析も正規表現でフォールバック
else -> resolveByNumericPattern(question, today)
}
}
}
ここでは、「年の指定がない場合は、未来の月なら前年、過去の月なら当年と推定する(例:6月時点で『8月何した?』と問われた場合は前年の8月を指す)」といった時間推論のルールを定義した。 これにより、検索対象となる日付を高い精度で特定できるようになった。
ステップB:メタデータのプレフィックス付与と特定の検索結果の優先表示
次に、検索システムが抽出した日記の本文をLLMに入力する際、コンテキスト情報だけではLLMが記述内容の記述日を特定できない。
そこで、ファイルのメタデータから [日付: YYYY-MM-DD] という日付情報プレフィックスを抽出し、各テキストスニペットの先頭へ挿入する処理を加えた。
[日付: 2026-01-02] 初回訪問日: 2022/01/02。サウナしきじに到着。やっぱり水風呂が神すぎる...
また、検索システムに組み込んでいるReranker(検索結果の適合度を再計算して順位を並べ替えるモデル)の評価によって、対象期間内の日記が検索順位の下位に埋もれる現象が発生した。 これを防ぐため、DateResolverによって特定された日付範囲に合致するファイルを、Rerankerのスコアに関わらずコンテキストの最上位(先頭から最大5件)へ強制的に配置する処理(ピン留め)を検索パイプラインの末尾に導入した。
ステップC:プロンプトへの期間制約の注入
さらに、回答生成用のプロンプトに対して、以下のように特定された対象期間の情報を追加し、生成時の評価条件を指定した。
[日付: YYYY-MM-DD] をこの期間と照合し、最優先で根拠にしてください。」
この指示を追加することで、Gemma 2Bは入力された日記スニペットの日付情報とユーザーの質問に含まれる時期を関連付けて解釈できるようになった。 その結果、「去年の冬は、〇〇サウナに行っていましたね」と、質問に適合した回答を生成しやすくなった。
4. チューニングによる改善結果
これらの個別対策を統合した結果、実機テストにおける回答の再現率と生成品質の向上が確認できた。
改善した要素
- 日時指定クエリへの応答精度向上:日付情報を含む質問に対する検索漏れが減少した。
- 応答遅延の削減:時間に関するクエリであることを検知した段階で、LLMによる回答可否の判定処理をスキップして検索・回答フェーズへ遷移する短絡処理(ショートサーキット)を導入することで、応答までの所要時間を数秒短縮した。
- インデックス作成速度の向上:SQLiteにおけるインデックスの更新処理をトランザクション内で実行するように改めた結果、インデックス作成の処理速度が向上した。
今後の課題
- ルールベースの限界:現状の実装では、「一昨々日(さきおととい)」のような例外的な相対時間表現には対応できないと指摘されるかもしれない。しかし、この課題に対しては、ルール定義の追加や、デバイス内で動作する軽量な時間表現解析モジュールへの移行によって解決を図る予定である。
オンデバイスRAGの設計指針
軽量なオンデバイスLLMを用いてRAGシステムを構築する際は、推論処理をLLMに依存する部分と、ルールベースのプログラムで解決する部分とを明確に分ける設計が重要である。 特に日付や時間といった構造化しやすい情報は、LLMの推論に頼るよりも、アプリケーション側の正規表現や静的ルールで解析し、検索クエリに変換したほうが処理速度と検索精度の面で有利になる。
オンデバイスLLMの処理能力の低さを理由に実用性を諦めるのではなく、検索エンジニアリングとコンテキストの構成方法を最適化することで、端末内で完結する応答精度の高いアシスタントアプリを開発できる。 本稿で紹介した手法が、ローカル環境でのRAGチューニングに取り組む開発者の参考になれば幸いである。