第8回:MySQL→Firestore移行と本番運用 — RDBからNoSQLへの道

はじめに

連載の最終回です。これまで、LLMファインチューニングによる株価予測と翻訳LLMの選定についてお伝えしてきました。

今回は、作ったデータをユーザーに届けるまでの「最後の1マイル」——MySQLのデータをFirestoreに移行し、Webサービスとして本番運用するまでの話です。

RDB(MySQL)に慣れた開発者がNoSQL(Firestore)に移行する際の苦労は、予想以上に多いものでした。インデックスの設計、Upsert戦略、ドキュメントIDの設計、そして何よりもFirestoreの書き込み課金との戦い。これらの経験が、これからFirestoreを導入する方の参考になれば幸いです。


なぜFirestoreなのか

千里眼サービスのアーキテクチャでは、データの流れは以下のようになっています。

meloik (PHP/MySQL) → assetai_firebase (Python) → stockSite (Next.js/Vercel)
     VPS                    VPS                        Vercel

フロントエンド(stockSite)はVercel上のNext.jsアプリです。このフロントエンドからデータを取得する方法として、いくつかの選択肢がありました。

  1. MySQLに直接アクセス: セキュリティ上、フロントエンドからRDBに直接接続するのは好ましくない
  2. APIサーバーを構築: REST APIを作ってMySQLからデータを返す。しかし、APIサーバーの構築と運用のコストがかかる
  3. Firestoreを中間層として使う: Firebase SDKでフロントエンドから安全にデータ取得。サーバーレスで運用コストが低い

個人開発では運用の手間を最小化したい。Firestoreならサーバー管理が不要で、Firebase SDKを使えばフロントエンドから直接データを取得できます。また、Firestoreのセキュリティルールでアクセス制御も可能です。


インデックスの違い — 最初の壁

MySQLからFirestoreに移行して最初に戸惑ったのは、インデックスの扱いでした。

MySQLの場合

MySQLでは、インデックスはテーブル定義時やALTER TABLEで一括定義できます。

-- 複合インデックスも簡単に作れる
CREATE INDEX idx_code_date ON ai_news (code, target_date);

-- 複数カラムの複合インデックスも同様
CREATE INDEX idx_code_type_date ON ai_news (code, type, target_date);

phpMyAdminのようなGUIツールで直感的にテーブルを見渡し、インデックスの追加も数クリックで完了します。

Firestoreの場合

Firestoreでは話がまったく違います。

  • 単一フィールドのインデックスは自動作成される
  • しかし、複合インデックスは1つずつ手動で作成する必要がある
  • Firebase ConsoleのUIから作るか、firestore.indexes.jsonで定義する
  • クエリを実行して初めて「このクエリにはインデックスが必要です」とエラーが出る
Error: The query requires an index. You can create it here:
https://console.firebase.google.com/v1/r/project/xxx/firestore/indexes?create_composite=...

エラーメッセージ内にインデックス作成用のリンクが含まれているのは親切ですが、そもそもクエリを実行するまでインデックスが必要かどうかわからないというのは、MySQL経験者にとっては違和感がありました。

さらに、インデックスの作成には数分〜十数分かかることがあります。MySQLならALTER TABLEで即座に反映されますが、Firestoreでは「Building」状態がしばらく続きます。


Firebase ConsoleのUI — MySQL経験者には辛い

Firebase ConsoleのUIは、ドキュメント指向データベースに特化した設計です。MySQLに慣れた開発者にとっては、いくつかの点で辛いものがありました。

テーブル全体を見渡せない

phpMyAdminでは、テーブルの全レコードを一覧表示し、ソートやフィルタリングが自由にできます。Firestoreのコンソールでは、コレクション内のドキュメントを1つずつ確認する形になり、全体像を掴みにくい。

SQLが使えない

SELECT * FROM news WHERE code = '4972' AND target_date >= '20241101' ORDER BY target_date DESC

こうしたクエリをサッと書いてデータを確認する、ということができません。Firestoreにはクエリビルダーがありますが、SQLの柔軟さには及びません。

JOINがない

MySQLでは当然のように使えるJOIN(テーブル結合)が、Firestoreにはありません。関連データを取得するには、複数回のクエリを実行してアプリケーション側で結合する必要があります。


Upsert戦略の進化

最も苦労したのが、同じデータを重複して書き込まないための仕組みです。Firestoreは書き込み課金のため、無駄な書き込みはコストに直結します。

最初の実装 — 文字列比較(バグあり)

# 最初のバージョン: JST文字列の比較(問題あり)
def save_to_firestore(collection_name, doc_id, data):
    doc_ref = db.collection(collection_name).document(doc_id)
    existing_doc = doc_ref.get()

    if existing_doc.exists:
        existing_data = existing_doc.to_dict()
        if existing_data.get("update_date", "0000-00-00 00:00:00") >= data["update_date"]:
            return False  # 既存データが新しければスキップ

    doc_ref.set(data)
    return True

一見合理的ですが、文字列比較によるタイムスタンプ比較にはバグが潜んでいました。タイムゾーン処理の曖昧さや、文字列フォーマットの微妙な違いによって、比較結果が正しくないケースが発生しました。

改善版 — UNIX epoch比較

文字列比較の問題を解決するために、UNIX タイムスタンプ(エポック秒)での整数比較に切り替えました。

# JST文字列 → エポック秒(UTC)に変換
def jst_string_to_epoch(s):
    if not s:
        return None
    try:
        JST = timezone(timedelta(hours=9))
        dt = datetime.strptime(s, "%Y-%m-%d %H:%M:%S").replace(tzinfo=JST)
        return int(dt.timestamp())
    except Exception:
        return None

# 改善版: UNIX タイムスタンプ(整数)で比較
def save_to_firestore(collection_name, doc_id, data, force=False):
    doc_ref = db.collection(collection_name).document(doc_id)
    existing_doc = doc_ref.get()

    new_epoch = data.get("updated_at_epoch")
    if new_epoch is None:
        new_epoch = jst_string_to_epoch(data.get("update_date"))

    if existing_doc.exists and not force:
        existing_data = existing_doc.to_dict()
        old_epoch = existing_data.get("updated_at_epoch")
        if old_epoch is None:
            old_epoch = jst_string_to_epoch(existing_data.get("update_date"))

        # 既存が新しければ書き込みスキップ(コスト削減)
        if old_epoch and new_epoch and old_epoch >= new_epoch:
            return False

    doc_ref.set(data)
    return True

ポイントは以下の通りです。

  • UNIX エポック秒で整数比較: タイムゾーンの曖昧さを完全に排除
  • forceパラメータ: データ修復時に強制上書きが必要な場合に対応
  • doc_ref.set()を使用: merge/updateではなくドキュメント全体を上書き。部分更新のバグを防ぐ
  • フォールバック: updated_at_epochがない場合はupdate_date文字列からエポック秒を計算

ドキュメントIDの設計

FirestoreのドキュメントIDをどう設計するかも重要な判断でした。

最初の方式 — SHA-1ハッシュ(デバッグ困難)

def generate_doc_id(news):
    base_str = f"{news['code']}_{news['target_date']}_{news['type']}"
    return hashlib.sha1(base_str.encode('utf-8')).hexdigest()
    # → "a3f8b2c1d4e5f6..." のような読めないID

SHA-1ハッシュを使えば一意性は保証されます。しかし、Firebase Consoleでドキュメントを確認する際、IDがa3f8b2c1d4e5f6...のような文字列では、どのニュースのデータなのかまったくわからないという問題がありました。

改善版 — 人間が読めるID

# type 日本語 → 英語マッピング
TYPE_MAPPING = {
    "PR情報": "pr",
    "決算": "earnings",
    "修正": "revision",
    "業績予想": "forecast",
    "配当予想": "dividend_forecast",
    # ...
}

def generate_readable_doc_id(news):
    code = str(news["code"])
    target_date = str(news["target_date_ymd"])
    type_en = TYPE_MAPPING.get(str(news.get("type", "")), "other")
    return f"{target_date}_{code}_{type_en}"
    # → "20250315_2413_earnings" のような読みやすいID

20250315_2413_earnings であれば、「2025年3月15日の銘柄コード2413の決算ニュース」と一目でわかります。Firebase Consoleでのデバッグが格段に楽になりました。

日本語のニュース種別(「決算」「修正」など)を英語に変換するマッピングテーブルが必要ですが、一度作ってしまえば管理は容易です。


Decimal型の変換

MySQLからPythonで取得したデータには、Decimal型の値が含まれることがあります。FirestoreはDecimal型を直接扱えないため、floatに変換する必要があります。

from decimal import Decimal

def convert_decimal_to_float(data):
    for key, value in data.items():
        if isinstance(value, Decimal):
            data[key] = float(value)
    return data

地味ですが、この変換を忘れるとFirestoreへの書き込み時にエラーになります。MySQL + Pythonの組み合わせでFirestoreに書き込む場合は、常にDecimal型の存在を意識する必要があります。


Firestoreの書き込み課金との戦い

Firestoreは読み取り・書き込み・削除のそれぞれに課金されます。千里眼サービスでは、月間の読み取りが約8,593万回に達していた時期がありました。

コスト削減策1: ISRのrevalidate延長

stockSite(Next.js)側でISR(Incremental Static Regeneration)のキャッシュ時間を延長しました。

// revalidateを300秒(5分)に設定
export const revalidate = 300;

5分間はキャッシュされた静的ページが返されるため、Firestoreへの読み取りリクエストが大幅に減少します。

コスト削減策2: 未チェックニュースの同期停止

以前はis_checked=0(AIによるチェックが完了していない)のニュースもFirestoreに同期していましたが、これを停止しました。チェック済みのニュースのみを同期することで、書き込み回数を削減しています。

コスト削減策3: Upsert戦略

前述のUpsert戦略により、既に最新のデータがFirestoreにある場合は書き込みをスキップします。これにより、無駄な書き込み(同じデータの再書き込み)が発生しなくなりました。


本番運用の全体像

千里眼サービスの本番運用を支えるバッチ処理群を整理します。

meloik VPS(データ生成・予測)

バッチ機能スケジュール
news_sokuhou_kabutanニュース取得平日定期実行
predict_stock_realtime2AI予測実行ニュース取得後に実行
translate_english英語翻訳予測完了後に実行
summerize_news_msiMSIニュース要約定期実行

assetai_firebase VPS(Firestore同期)

スクリプト機能スケジュール
import_news_realtime.shチェック済みニュース同期平日15分毎
import_news.shニュース一括同期1日4回
import_companies.sh企業情報同期週1回

データフロー(時系列)

08:30  市場開場前にニュース収集バッチが起動
       └─ 適時開示等の公開情報を収集、MySQLに格納

09:00  予測バッチが起動
       ├─ 企業情報・株価・財務・マクロ指標をDBから取得
       ├─ ファインチューニング済みLLMに予測リクエスト
       └─ 予測結果をMySQLに保存

09:15  翻訳バッチが起動
       └─ ニュース・予測理由を英語に翻訳

09:30  Firestore同期(15分毎)
       └─ MySQLの差分をFirestoreにエクスポート

随時   stockSite(Web UI)
       └─ FirestoreからISR経由でデータ取得、画面表示

これらすべてcrontabで管理されており、人間の操作なしに毎日自動で動き続けています。


現在のLLMモデル構成

本番で使用しているLLMモデルの全体像です。

処理モデル用途
株価予測gpt-4o-mini(FT済み)ファインチューニング済みモデルで翌日の株価変動を予測
予測理由生成gpt-5-nanoニュース分析に基づく予測理由の生成
翻訳・要約gpt-5-mini日英翻訳、ニュース要約

3つの異なるモデルを、それぞれの用途に応じて使い分けています。株価予測だけがファインチューニング済みモデルで、他は汎用モデルを使っています。


連載のまとめ

全8回にわたって、株価予測LLMの構築からWebサービスの本番運用までをお伝えしてきました。最後に、連載全体を通じた学びをまとめます。

技術的な学び

  1. データの質 > モデルの大きさ: 7〜8Bのオープンソースモデルで精度が出なかった原因は、モデルの能力だけでなくデータの質と量にもあった。OpenAI APIでフルデータを投入できたことが精度向上の大きな要因
  2. ファインチューニングが「動く」と「使える」は別物: LoRA + 量子化でファインチューニング自体は動いたが、実用的な精度が出るかは別問題
  3. モデルの基礎能力が決定的: JSON形式での安定した出力など、インストラクション追従能力はベースモデルの基礎能力に依存する
  4. LLMプロバイダはコストだけで選ばない: DeepSeekの例のように、安さに惹かれると品質面で問題が出る可能性がある
  5. RDB→NoSQLの移行は想像以上に大変: インデックス、Upsert、ドキュメントID設計など、RDBの常識が通用しない場面が多い

個人開発としての学び

  1. 段階的に進める: ローカル → Colab → OpenAI API と段階的に進めたことで、各段階で技術を深く理解できた
  2. 失敗を恐れない: Phase 1-2は「失敗」だったが、そこで得た知見がPhase 3の成功を支えた
  3. 現実的な判断をする: 「自分だけのLLM」への憧れよりも、精度とコストパフォーマンスを優先する現実的な判断が重要
  4. 自動化を最優先: crontabによるバッチの自動化で、日々の運用コストをほぼゼロにできた

今後の展望

  1. 訓練データの拡充: 現在の1,009件から数千件規模へ。業種と時期の多様性を高める
  2. 蒸留によるローカル化: ファインチューニング済みモデルの知識を小さなモデルに転写し、API費用をゼロにする
  3. 予測精度の評価と改善: 体系的な精度評価の仕組みを構築し、継続的に改善
  4. データの多角化: テキストだけでなく、チャート画像なども入力に含めるマルチモーダル化

おわりに

この連載を通じてお伝えしたかったことは、個人開発でもLLMファインチューニングは実現可能であるということです。

最新のGPUクラスタや大規模なインフラがなくても、工夫次第でLLMを自分のサービスに組み込める。ローカルGPUでの挫折も、Colabでの試行錯誤も、すべてが最終的な成功への糧になりました。

千里眼サービス(https://senrigan.tech/)は今も毎日、自動で株価予測を実行し、その結果を公開しています。完璧なサービスではありませんが、個人の力でここまで作れたことに、自分なりの手応えを感じています。

この連載が、LLMファインチューニングや個人開発のWebサービス構築に興味を持つ方の参考になれば、これ以上の喜びはありません。

最後まで読んでいただき、ありがとうございました。


シリーズ「ニュースで株価を予測するLLMを作った話」 完

この記事をシェア

関連記事