AIバッチ処理のボトルネック解消術
対象読者: AIエージェントでバッチ処理を回しているエンジニア。Ollama / ChromaDB / SBERT の基礎を理解している方。前編「HBO パターン」の続編として読むと効果的。 本書の目的: ローカルLLMサーバーのボトルネックを構造的に理解し、LLMを使わずに同等の結果を得る「ファネル脱出」戦略を実践できるようになる。
目次
第I部 技術概念と原理
- ボトルネックの構造 — なぜ Ollama は遅いのか
- ファネル脱出戦略 — LLMを呼ばずに分類を完了する
- SBERTクラスタ転写 — 既存の分類結果を未分類データに投影する
- キーワードパターン拡充 — 頻度分析からルールを自動生成する
- リソース排他性の物理学 — 何が並列可能で何が不可能か
第II部 用語辞典
第III部 システム構成
第IV部 歴史と背景
第V部 手順・マニュアル
第I部 技術概念と原理
1. ボトルネックの構造
問題の定量化
あるプロジェクトで45,121件の特許を技術カテゴリに分類するバッチ処理を実行した。3段階ファネル(ルール → キーワード → LLM)を使い、Phase 1+2で43.7%を即時分類した。残りの56.3%はOllama(ローカルLLMサーバー、8Bモデル)で処理した。
しかし、Ollamaの処理速度は251件/時。残り13,614件の完了には54時間かかる見込みだった。
45,121件
|
Phase 1 (IPC code): 14,691件 → 即完了 (32.6%)
|
Phase 2 (keyword): 5,015件 → 即完了 (11.1%)
|
Phase 3 (Ollama): 25,415件 → 251件/時 → 101時間
| ↑ ボトルネック
v
途中経過: 11,801件完了、13,614件残り → あと54時間
なぜ251件/時なのか
1件あたり14.3秒の内訳:
HTTP request overhead: 0.1秒 (ネットワーク往復)
Prompt tokenization: 0.2秒 (入力テキスト → トークン)
Prefill (入力処理): 2.0秒 (KVキャッシュ構築)
Decode (出力生成): 10.0秒 (1トークンずつ生成)
Response parsing: 0.1秒 (JSON → Python)
Think tokens overhead: 2.0秒 (qwen3の思考トークン)
------
合計: 14.4秒/件
Decode(出力生成)が70%を占める。LLMは出力を1トークンずつ逐次生成するため、「WPT」という3文字の回答でも、思考過程を含めると数十トークンを生成している。
Ollamaの排他性
Ollamaはリクエストをキュー方式で処理する。1つのリクエストが推論中は、他のリクエストは待機する。3プロセスが同時にリクエストすると:
プロセスA: [===推論===] [===推論===] [===推論===]
プロセスB: [===推論===] [===推論===]
プロセスC: [===推論===] [===推論===]
時間軸: |----|----|----|----|----|----|----|----|
0 5 10 15 20 25 30 35秒
→ 各プロセスの実効速度: 1/3に低下
→ 全体スループット: ほぼ変わらない(GPUは1つ)
結論: Ollamaのスループットは物理的にGPU 1基で上限がある。並列化しても速くならない。
2. ファネル脱出戦略
ボトルネックを「速くする」のではなく、「通らなくて済むようにする」 という発想。
従来の3段階ファネル ファネル脱出
45,121件 45,121件
| |
Phase 1: 14,691件 Phase 1: 14,691件
| |
Phase 2: 5,015件 Phase 2: 5,015件 (v1)
| |
Phase 3: 25,415件 Phase 2': 2,209件 (v2拡充)
↑ Ollama 101時間 |
Phase 2'': 4,061件 (SBERT転写)
|
Phase 3: 11,801件 (既に完了分のみ)
|
残り: 7,344件 (データ無し → UNKNOWN)
→ Ollama追加呼び出し: 0件
→ 所要時間: 数秒
ファネル脱出の2つの武器:
- キーワードパターン拡充 (Phase 2'): 未分類タイトルの頻出語分析 → 新規正規表現パターン追加
- SBERTクラスタ転写 (Phase 2''): 既分類データのクラスタ所属から、未分類データのカテゴリを推定
どちらもLLMを一切使わない。CPUで即時完了する。
3. SBERTクラスタ転写
原理
事前にSBERT(Sentence-BERT)で全データをembeddingし、KMeansでクラスタリングしてあるとする。27,776件が30クラスタに分かれている。
このとき、各クラスタには「既にLLMで分類済みの特許」と「未分類の特許」が混在している。同じクラスタに属する特許は意味的に類似しているため、既分類データの多数決カテゴリを未分類データに転写できる。
Cluster 7 (2,478件):
├── 2,200件: auto_category = "WPT" (LLMで分類済み) → 96%
├── 200件: auto_category = "EH" → 4%
└── 78件: auto_category = NULL (未分類)
→ 多数決で "WPT" を転写
精度の考察
クラスタの「純度」(最頻カテゴリの比率)が信頼度の指標になる:
| 純度 | 解釈 | 転写の信頼性 |
|---|---|---|
| > 90% | ほぼ単一カテゴリ | 高い。安全に転写可能 |
| 70-90% | 支配的カテゴリあり | 中程度。誤分類リスクあり |
| < 70% | 混合クラスタ | 低い。LLM確認が望ましい |
実際の結果では、30クラスタ中20クラスタが純度70%以上だった。4,061件を転写し、LLMを完全に回避した。
なぜ「分類」にLLMを使うのがオーバースペックなのか
LLM分類の本質は「テキストの意味を理解してカテゴリに振り分ける」こと。しかし:
- SBERT embedding: テキストの意味をベクトルとして捉えている
- KMeans clustering: 意味的に近いものをグループ化している
- 多数決転写: グループ内の既知ラベルから未知ラベルを推定している
これはk-近傍法(kNN)の特殊ケースだ。LLMが1件15秒かけてやる推論を、SBERTクラスタは数十ミリ秒で近似できる。精度は若干劣るが、バッチ分類の用途では十分。
4. キーワードパターン拡充
原理
Phase 2のキーワード正規表現パターンは、最初に設計した時点の知識に基づいている。しかし実データを見ると、設計時に想定しなかったパターンが大量に存在する。
未分類データのタイトルを頻度分析すると:
ceramic: 851件 → 電子部品(セラミックコンデンサ等)
battery: 715件 → 電池
capacitor: 575件 → コンデンサ
multilayer: 557件 → 積層セラミック
substrate: 485件 → 基板
これらは明らかに特定のカテゴリに属するが、初期のキーワードパターンではカバーされていなかった。
拡充の手法
# Step 1: 未分類タイトルの頻出語をカウント
words = Counter()
for row in unclassified:
for w in re.findall(r'[a-z]{3,}', row['title'].lower()):
words[w] += 1
# Step 2: 頻出語からカテゴリパターンを設計
EXTRA_KEYWORDS = {
"BATTERY": [r"\bbatter", r"electrolyte", r"lithium"],
"OTHER": [r"ceramic.?(electronic|component)", r"multilayer.?ceramic"],
"SIGNAL_PROC": [r"\bfilter\b.*(circuit|frequency)", r"resonator"],
# ...
}
# Step 3: 未分類データに適用
for row in unclassified:
for cat, patterns in EXTRA_KEYWORDS.items():
if any(re.search(p, row['title'], re.I) for p in patterns):
row['auto_category'] = cat
break
この手法で2,209件を追加分類した。所要時間は数秒。
Phase 2 vs Phase 3 の費用対効果
| 手法 | 速度 | 精度 | コスト | 適用量 |
|---|---|---|---|---|
| Phase 1 (IPCコード) | 即時 | 99%+ | ¥0 | 14,691件 |
| Phase 2 (キーワード) | 即時 | 95%+ | ¥0 | 5,015件 |
| Phase 2' (キーワード拡充) | 即時 | 90%+ | ¥0 | 2,209件 |
| Phase 2'' (SBERT転写) | 即時 | 85%+ | ¥0 | 4,061件 |
| Phase 3 (Ollama LLM) | 14.4秒/件 | 95%+ | 電気代のみ | 11,801件 |
Phase 2の拡充とSBERT転写で6,270件を救出し、Ollamaの追加稼働54時間をゼロにした。
5. リソース排他性の物理学
ボトルネック解消後、解放されたリソースをどう再配分するか。各リソースの排他性を理解する必要がある。
リソース排他性マトリクス
読み 書き 推論
SQLite 共有 排他 -
ChromaDB 共有 排他 -
Ollama embed - - 準排他(軽量、並列許容)
Ollama LLM - - 排他(重い、キュー待ち)
SBERT CPU - - 共有(メモリ依存)
SSH 共有 共有 -
排他性の原因
SQLite: WAL(Write-Ahead Logging)モードでも、同時書込はシリアライズされる。複数プロセスが同じDBに書き込むとdatabase is lockedエラー。
ChromaDB: 内部でSQLiteを使用。加えて、コレクション操作がプロセス間で共有されない。別プロセスがdelete_collectionした後にaddするとCollection does not existエラー。
Ollama LLM推論: GPU演算ユニットは物理的に1つ。KVキャッシュもVRAMを排他的に消費。OLLAMA_NUM_PARALLELで擬似並列が可能だが、VRAM容量に制約される。
SBERT CPU: メインメモリ上で動作。モデルロード(384MB)後は複数プロセスが同時にencode可能。ただしメモリ帯域で律速される。
最適リソース配分戦略
Ollama解放後の配分
+----------------------------------+
| |
+-----v-----+ +--------+ +-----------+
| Ollama GPU | |MBP CPU | | SQLite |
| (解放済み) | | (空き) | | (解放済み)|
+-----+------+ +----+---+ +-----+-----+
| | |
[S: CatX 23K] [T: whisper] [U: CSV import]
scoring 文字起こし 619件新規投入
(qwen3:14b) (CPU集中) (即完了)
ルール: 1. Ollama LLMタスクは1つに集中(S: Cat X scoring) 2. CPU集中タスク(T: whisper)はOllamaと並行OK 3. SQLite書込は短時間で終わらせ、ロックを即解放
第II部 用語辞典
6. 用語辞典
分類・機械学習
| 用語 | 意味 |
|---|---|
| 3段階ファネル | ルール → キーワード → LLM の段階的フィルタリング。安価な手法で先にふるい、LLMの負荷を最小化する設計パターン |
| SBERTクラスタ転写 | SBERT embeddingのクラスタリング結果を使い、既分類データのラベルを未分類データに投影する手法。kNNの特殊ケース |
| 多数決カテゴリ | クラスタ内の最頻出カテゴリ。純度(最頻カテゴリの比率)が信頼度の指標 |
| KMeans | k個のクラスタ中心にデータを割り当てるクラスタリングアルゴリズム。SBERT embeddingとの組合せが定番 |
| kNN(k近傍法) | 距離が近いk個のラベル付きデータから多数決でラベルを推定する分類手法 |
| Phase 2拡充 | 実データの頻度分析に基づいてキーワードパターンを追加し、ルールベース分類のカバレッジを拡大する手法 |
パフォーマンス
| 用語 | 意味 |
|---|---|
| ファネル脱出 | LLM推論のボトルネックを「速くする」のではなく「通らなくて済むようにする」戦略 |
| スループット | 単位時間あたりの処理件数。Ollama LLMの場合、GPU 1基で上限がある |
| OLLAMA_NUM_PARALLEL | Ollama の環境変数。同一モデルの並列リクエスト数を制御。VRAMに余裕があれば2-4並列可能 |
| Think tokens | qwen3等の推論モデルが出力する「思考過程」トークン。分類タスクでは不要だがオフにできない場合がある |
| Prefill | LLMがプロンプトを処理してKVキャッシュを構築する段階。入力長に比例 |
| Decode | LLMが出力トークンを1つずつ生成する段階。出力長に比例。ボトルネックの主因 |
データベース
| 用語 | 意味 |
|---|---|
| WAL (Write-Ahead Logging) | SQLiteのジャーナルモード。読み書きの並行性を向上させるが、同時書込のシリアライズは避けられない |
| database is locked | SQLiteの同時書込エラー。原因は常に「複数プロセスが同じDBファイルに書込」 |
| Collection does not exist | ChromaDBのエラー。別プロセスによるコレクション操作の競合が原因 |
第III部 システム構成
7. ファネル脱出のデータフロー
patent_search.db (45,121件)
|
| SELECT id, title, ipc, cpc, auto_category
| WHERE auto_category IS NULL
|
v
[6,270件: 未分類 + title有り]
|
+---- Phase 2' (キーワード拡充) --------+
| | |
| | 頻度分析: |
| | "ceramic": 851件 |
| | "battery": 715件 |
| | → 新パターン設計 |
| | |
| +---> 2,209件を即時分類 |
| |
+---- Phase 2'' (SBERT転写) -----------+
| | |
| | bizc_27k_clusters.csv (30 clusters)
| | + |
| | patent_search.db (既分類21,506件) |
| | → cluster × category 多数決 |
| | |
| +---> 4,061件を即時分類 |
| |
v |
[残り0件: title有りは全件分類完了] |
+ |
[7,344件: title/IPC全てNULL] |
→ "UNKNOWN" (データ不足で分類不能) |
|
合計所要時間: 数秒 |
Ollama呼び出し: 0回 |
→ Ollama帯域を次タスクに全解放 |
8. リソース競合マップとスケジューリング
Before(ボトルネック状態)
Mac mini GPU: [G: 分類 Phase3 qwen3:8b]============> 54h占有
MBP CPU: (空き)
SQLite: [G が断続的にロック]
→ 他のOllamaタスクが実行不能
After(ファネル脱出後)
Mac mini GPU: [S: CatX scoring qwen3:14b]=========>
MBP CPU: [T: whisper 24ファイル]=======>
SQLite: [U: CSV import](即完了)
→ 3タスクが資源分離で並行稼働
→ Ollama帯域を高価値タスク(S)に集中
スケジューリング判断ツリー
ボトルネック検出
|
+-- 残り件数は? → 6,270件(title有り)+ 7,344件(データ無し)
|
+-- SBERT embeddingは事前にある? → YES(27K件30クラスタ)
| |
| +-- 未分類データとクラスタの重複は? → 100%(6,270/6,270)
| |
| +-- → SBERTクラスタ転写を適用
|
+-- キーワード拡充の余地は? → YES(頻度分析で新パターン発見)
| |
| +-- → Phase 2' を適用
|
+-- データ無し件数は? → 7,344件(分類不能)
|
+-- → UNKNOWN でマーク、プロセスkill、Ollama解放
第IV部 歴史と背景
9. なぜこの問題が生まれたか
3段階ファネルは「Phase 3のLLM呼び出しを最小化する」設計思想で作られた。Phase 1(IPC)で32.6%、Phase 2(キーワード)で11.1%を削減し、LLMに到達するのは56.3%に抑えた。
しかし、56.3%でも25,415件。LLMの処理速度(251件/時)では101時間かかる。
根本原因: ファネルの設計時に「Phase 2のカバレッジ」が十分に検討されなかった。初期キーワードパターンは主要カテゴリ(WPT, RFID, ANTENNA等)のみをカバーし、周辺カテゴリ(BATTERY, ceramic電子部品等)のパターンが不足していた。
初期設計時の想定:
Phase 1: 30% → Phase 2: 30% → Phase 3: 40%
実際:
Phase 1: 32.6% → Phase 2: 11.1% → Phase 3: 56.3%
↑ ここが想定の1/3
さらに、SBERT embeddingとクラスタリングはバッチ処理の「成果物」として事後的に生成されたが、分類タスクへのフィードバックが設計に含まれていなかった。
10. 分類精度 vs 速度のトレードオフ史
1950s ルールベース分類(if-then)
| 速度: 極めて高速 精度: ルールの質に依存
|
1990s 機械学習(SVM, Naive Bayes)
| 速度: 高速 精度: 特徴量設計に依存
|
2018 BERT / Sentence-BERT
| 速度: 中程度(バッチ処理可能) 精度: 高い
| → embedding + kNNで分類が可能に
|
2023 LLM直接分類(GPT-4, Claude, Ollama)
| 速度: 遅い(1-15秒/件) 精度: 非常に高い
| → ゼロショットで任意のカテゴリに分類可能
|
2026 ファネル脱出パターン(本書)
ルール → キーワード → SBERT転写 → LLM
速度: 大部分が即時 精度: LLMに準ずる
→ LLMは最終手段。到達する前に解決する
教訓: 最新の手法(LLM)が常に最適とは限らない。問題の構造に合わせて、古い手法と新しい手法を段階的に組み合わせるのが最も効率的。
第V部 手順・マニュアル
11. ボトルネック診断の5ステップ
前提条件
- ローカルLLMサーバー(Ollama等)でバッチ処理が稼働中
- 処理速度が期待より遅い、または完了見込みが許容範囲外
Step 1: 処理速度の定量化
# progress.json から速度を推定
cat data/.categorize_progress.json
# {"phase": "3", "processed": 11801, "total": 25415, "timestamp": "..."}
# 稼働時間と件数から速度を計算
python3 -c "
processed = 11801
hours = 47 # 稼働時間
remaining = 25415 - processed
rate = processed / hours
eta = remaining / rate
print(f'Rate: {rate:.0f} items/hour')
print(f'ETA: {eta:.1f} hours')
"
Step 2: ボトルネック箇所の特定
# Ollamaの現在の負荷
curl -s http://<サーバーIP>:11434/api/ps | python3 -c "
import sys,json
d = json.load(sys.stdin)
for m in d.get('models', []):
print(f'{m[\"name\"]}: VRAM {m.get(\"size_vram\",0)//1024//1024}MB')
"
# 複数プロセスがOllamaを叩いていないか
ps aux | grep -E "batch_|scoring|categorize" | grep -v grep
Step 3: 未分類データの分析
# 未分類データに何があるか
SELECT
SUM(CASE WHEN title IS NULL THEN 1 ELSE 0 END) as no_title,
SUM(CASE WHEN ipc IS NOT NULL THEN 1 ELSE 0 END) as has_ipc,
COUNT(*) as total
FROM patents WHERE auto_category IS NULL
Step 4: 脱出経路の判定
未分類データの特性を確認:
├── title有り + SBERTクラスタ有り → SBERT転写(最速)
├── title有り + SBERTクラスタ無し → キーワード拡充
├── title有り + 上記で漏れ → LLM続行(仕方ない)
└── title無し + IPC無し → UNKNOWN(分類不能)
Step 5: 実行と検証
# 1. バッチプロセスをkill(DBロック解放)
kill <PID>
# 2. ファネル脱出を適用(数秒で完了)
python3 scripts/funnel_escape.py
# 3. 結果確認
python3 -c "
import sqlite3
db = sqlite3.connect('data/patent_search.db')
total = db.execute('SELECT COUNT(*) FROM patents').fetchone()[0]
classified = db.execute('SELECT COUNT(*) FROM patents WHERE auto_category IS NOT NULL').fetchone()[0]
print(f'{classified}/{total} ({100*classified/total:.1f}%)')
"
# 4. Ollama解放 → 次タスク投入
12. SBERTクラスタ転写の実装手順
前提条件
- SBERT embeddingとKMeansクラスタリングが完了していること
- クラスタ結果がCSV(pub_number, cluster列)で保存されていること
- 分類済みデータがDBにある程度蓄積されていること
実装
import sqlite3, pandas as pd
# 1. クラスタデータ読込
clusters = pd.read_csv("examples/bizc_27k_clusters.csv")
# 2. 既分類データとマージ
db = sqlite3.connect("data/patent_search.db")
classified = pd.read_sql(
"SELECT pub_number, auto_category FROM patents WHERE auto_category IS NOT NULL", db)
merged = clusters.merge(classified, on='pub_number', how='inner')
# 3. クラスタ → カテゴリのマッピング(多数決)
cluster_map = {}
for cid in merged['cluster'].unique():
subset = merged[merged['cluster'] == cid]
majority = subset['auto_category'].value_counts().index[0]
confidence = subset['auto_category'].value_counts().iloc[0] / len(subset)
cluster_map[cid] = (majority, confidence)
# 4. 未分類データに適用
pub_to_cluster = dict(zip(
clusters['pub_number'].astype(str), clusters['cluster']))
remaining = db.execute(
"SELECT id, pub_number FROM patents WHERE auto_category IS NULL"
).fetchall()
for r in remaining:
cid = pub_to_cluster.get(str(r[1]))
if cid is not None and cid in cluster_map:
cat, conf = cluster_map[cid]
db.execute(
"UPDATE patents SET auto_category=?, category_method='sbert_cluster' WHERE id=?",
(cat, r[0]))
db.commit()
13. キーワード拡充の実装手順
Step 1: 頻出語分析
from collections import Counter
import re
rows = db.execute(
"SELECT title FROM patents WHERE auto_category IS NULL AND title IS NOT NULL"
).fetchall()
words = Counter()
for r in rows:
for w in re.findall(r'[a-z]{3,}', r[0].lower()):
words[w] += 1
# Top 40 を確認 → カテゴリに帰属できる単語を特定
for w, c in words.most_common(40):
print(f"{w:20s}: {c}")
Step 2: パターン設計
頻出語をカテゴリにグループ化:
EXTRA_KEYWORDS = {
"BATTERY": [r"\bbatter", r"electrolyte", r"lithium", r"solid.?state.?batter"],
"OTHER": [r"ceramic.?(electronic|component)", r"multilayer.?ceramic"],
# ... 頻出語の分析結果に基づいて設計
}
Step 3: 適用
for r in unclassified:
text = r['title'].lower()
for cat, patterns in EXTRA_KEYWORDS.items():
if any(re.search(p, text, re.I) for p in patterns):
db.execute(
"UPDATE patents SET auto_category=?, category_method='keyword_v2' WHERE id=?",
(cat, r['id']))
break
db.commit()
14. 解消後のリソース再配分
ボトルネック解消でOllamaが解放された後、リソースを再配分する。
判断基準
1. 最も価値の高いOllamaタスクを特定
→ Cat X scoring (23K件): 未処理の最大カテゴリ
2. CPU独立タスクを並行投入
→ 音声文字起こし (faster-whisper): CPUのみ、Ollamaは briefing時のみ
3. 短時間SQLiteタスクを即完了
→ inbox CSVインポート: 619件、数秒で完了
実行順序
# 1. 短時間タスク(即完了)
python3 import_inbox_csvs.py # SQLite write, 数秒
# 2. Ollamaメインタスク(バックグラウンド)
python3 relevance_scoring.py --input catX.csv & # 数時間
# 3. CPU並行タスク(バックグラウンド)
python3 audio_processor.py --inbox --skip-done & # whisper + 軽量Ollama
← 参照コンテンツ一覧に戻る本書の核心: ボトルネックに遭遇したとき、最初に考えるべきは「速くする方法」ではなく「通らなくて済む方法」。LLMは万能だが遅い。事前に蓄積したデータ(SBERT embedding、既分類結果)を活用すれば、LLMの仕事の大部分をLLMなしで完了できる。ファネル脱出は、過去の投資を現在の問題解決に転用する技術だ。