この記事は約 6 分で読めます。
筆者プロフィール: ソフトウェアエンジニア。「知った気にならない。いつまでも学び続ける」を信条に、業務と個人開発の両輪で技術を磨いています。AI 駆動開発で複数の個人開発アプリを構築・運用中。
👉 ポートフォリオ: 筆者ホームページ
外部 API (Voyage / Anthropic 等) を提案エンジンに使うと、「外部 API が落ちたらユーザに何を見せるか」 が必ず問題になります。本記事では、運用中の SaaS 「たすきば Knowledge Relay」 で採用した 重み再配分縮退モード の設計判断を整理します。
サービスの機能紹介・画面イメージ・コンセプトは公式プロダクトページをご覧ください。
👉 たすきば Knowledge Relay — 公式プロダクトページ
なぜハードカット (429) を採用しないか
業務 SaaS で API 利用上限を超えたとき、よくある対応は HTTP 429 Too Many Requests を返してユーザを止める こと。
—— たすきばは、この方式を 採用しません。
| 理由 | 内容 |
|---|---|
| ユーザの作業が中断される | 「リスクを起票したい」のに API 制限で止まると、ユーザは何もできない |
| 本体データの保存まで止まる | 提案に使う付随処理 (embedding 生成) のために、メインデータの保存まで止めるのは過剰 |
| コスト超過は予測不能 | ユーザはどこまで使ったら制限に当たるか事前には分からない |
代わりに、Graceful Degradation Mode (ADR-0008) を採用しました。
「本体データは正常保存。提案精度だけが下がる」
という縮退設計です。
1. 縮退時の挙動
| 操作 | 平常時 | 縮退時 |
|---|---|---|
| 新規ナレッジ作成 | embedding 生成 + 保存 | embedding は NULL のまま保存 |
| 新規リスク起票 | embedding 生成 + 保存 | embedding は NULL のまま保存 |
| 提案画面表示 (既存) | 3 軸合算スコアでランキング | 3 軸合算スコア (NULL は意味類似度 0 として扱う) |
| 提案画面表示 (縮退中の新規) | 3 軸合算スコア | 重み再配分縮退モード に自動遷移 |
重要: 本体データの保存は常に成功します。ユーザの作業フローは止まりません。
2. 重み再配分の仕組み
平常時の重み:
タグ類似度 × 0.3 + 文字列類似度 × 0.2 + 意味類似度 × 0.5 = 最終スコア
縮退時 (embedding が NULL):
タグ類似度 × 0.6 + 文字列類似度 × 0.4 + 意味類似度 × 0.0 = 最終スコア
↑
ここを 0 にし、
その分を残り 2 軸に再配分
具体的には:
- 意味類似度の重み 0.5 を、残り 2 軸の比率で按分
- タグ : 文字列 = 0.3 : 0.2 = 3 : 2
- 0.5 を 3 : 2 で按分 → +0.3 と +0.2
- 新しい重み: タグ 0.3 + 0.3 = 0.6、文字列 0.2 + 0.2 = 0.4
合計の重みは常に 1.0 を維持します。
3. なぜ「NULL を除外」ではなく「重み再配分」か
「embedding NULL の候補をフィルタアウトする」設計も検討しましたが、不採用にしました。
| 不採用案 (NULL を除外) | 問題点 |
|---|---|
| - | 作りたて (縮退中作成) の候補が永遠に提案に乗らない |
| - | 縮退と平常で提案結果が極端に違う |
| - | 月初 cron で補完しても、補完までユーザに見えない |
「全候補は常に同じ土俵 (3 軸合算) で評価される」 というポリシーを維持することで、縮退・平常の切替が ユーザに見えない 設計になります。
4. 実装イメージ
// src/services/suggestion.service.ts
export async function findSuggestions(
queryEmbedding: number[] | null, // 縮退時は null
queryText: string,
queryTags: string[],
context: { viewerTenantId: string },
) {
const isDegraded = queryEmbedding === null;
const weights = isDegraded
? { tag: 0.6, text: 0.4, embed: 0.0 }
: { tag: 0.3, text: 0.2, embed: 0.5 };
return prisma.$queryRaw`
SELECT
id,
title,
tag_jaccard(business_domain_tags, ${queryTags}) * ${weights.tag}
+ similarity(title || ' ' || content, ${queryText}) * ${weights.text}
+ COALESCE(1 - (content_embedding <=> ${queryEmbedding}::vector), 0) * ${weights.embed}
AS final_score
FROM knowledges
WHERE tenant_id = ${context.viewerTenantId}
AND visibility = 'public'
AND deleted_at IS NULL
ORDER BY final_score DESC
LIMIT 10
`;
}
ポイント:
-
COALESCE(..., 0)で NULL を 0 に置換 - 重みを動的に切り替える (
isDegradedフラグ) - SQL 自体は同じクエリ (条件分岐を SQL の外に出す)
5. ハードキャップ超過時の挙動
「ハードキャップを超えても embedding を保存しない」のは、課金透明性 のためでもあります。
| 状態 | 挙動 |
|---|---|
| ハードキャップ未到達 | 通常の embedding 生成 + ApiCallLog 記録 + 課金 |
| ハードキャップ超過 | embedding 生成スキップ + 課金しない + 本体データは保存 |
| 翌月 cron | 前月分の NULL embedding を一括補完 (補完分は 無料化) |
これにより:
- ユーザは「当月の操作で当月分が請求される」という直感的なモデルを維持
- 月初 cron でデータが揃うので、翌月以降は通常の精度に戻る
6. fail-safe な提案として保証する 4 つのこと
ADR-0008 では、提案機能の SLA として以下を保証しています。
| 保証 | 内容 |
|---|---|
| 本体データの可用性 | 外部 API 障害時でも、ユーザのデータ保存は止まらない |
| 提案機能の可用性 | 外部 API 障害時でも、提案画面はエラーを返さない |
| 提案精度の縮退 | 縮退時は精度が下がるが、結果は返る |
| 後追い補完 | 月初 cron で前月分の NULL を補完 (無料) |
「100% の精度より、100% の可用性」を選ぶ設計判断です。
7. 横展開: 他機能でも同じパターン
この Graceful Degradation パターンは、たすきばの他の機能でも使っています。
| 機能 | 縮退モード |
|---|---|
| メール送信失敗 | ログだけ残し、ユーザ操作は完了させる |
| 添付ファイルのウイルススキャン失敗 | 一時的に「スキャン中」状態にし、アップロードは完了 |
| Stripe Webhook 失敗 | 内部キューに積み、ユーザの操作は通す |
「外部システムの障害を、ユーザに見せない」 をポリシーとして全機能で実践しています。
おわりに — 外部 API の障害をユーザに見せない
外部 API 連携を設計するとき、必ず「この外部 API が落ちたら、ユーザに何が見えるか」を考えます。
最悪のシナリオが「ユーザが何もできなくなる」ならば、それは設計が間違っています。
ユーザの作業は、外部 API の可用性に依存してはいけない。
外部 API は「あれば嬉しい補助機能」と位置づけ、それなしでもサービスのコア体験 (本体データ保存・閲覧) は完結する設計が望ましいです。
たすきばは、この思想を徹底するために、ADR-0008 を最初に書きました。
本記事の縮退モード設計は、運用中の SaaS 「たすきば Knowledge Relay」 で実装しています。
👉 たすきば Knowledge Relay — 公式プロダクトページ